从 Next.js 完全迁移到 vinext 的实战踩坑指南

280 阅读11分钟

一次真实项目从 Next.js (Cloudflare Pages) 迁移到 vinext (Cloudflare Workers) 的全过程记录,涵盖构建、部署、运行时、国际化、认证等 16+ 个坑位及其解决方案。

项目简介

本文基于开源项目 edge-next-starter 的真实迁移经验撰写。

edge-next-starter 是一个面向 Cloudflare 全栈开发的 Next.js 生产级启动模板,集成了 D1 数据库、R2 对象存储、KV 缓存、better-auth 认证、next-intl 国际化、Stripe 支付等企业级能力,开箱即用。项目采用 Repository 模式 + Edge Runtime 架构设计,所有代码均运行在 Cloudflare Workers 全球边缘网络上。

GitHub: github.com/TangSY/edge… ⭐ 欢迎 Star

背景与动机

2026 年 2 月 24 日,Cloudflare 发布了一篇震动 Web 开发社区的博客:How we rebuilt Next.js with AI in one week。一名 Cloudflare 工程师 Steve Faulkner 使用 Anthropic 的 Claude AI 模型,仅花费约 1100 美元的 token 费用,在一周内从零重建了 Next.js 94% 的 API。产物就是 vinext(发音 "vee-next")—— 一个基于 Vite 构建的 Next.js 替代实现,专门针对 Cloudflare Workers 优化。

Cloudflare CTO Dane Knecht 称之为「Next.js 的解放日」。项目绝大部分代码由 AI 编写,人类负责架构方向、优先级和设计决策。

vinext 相比传统的 @cloudflare/next-on-pages 或 OpenNext 方案,有以下显著优势:

  • 原生 Workers 部署:不再需要逆向工程 Next.js 的构建输出,一条命令直接部署
  • Vite 构建:取代 Turbopack/webpack,生产构建速度最高提升 4 倍
  • 更小的 bundle:客户端包体积最高缩小 57%
  • 开发环境一致性vite dev 直接在 Workers 运行时中运行,可以测试 D1、KV、Durable Objects 等平台 API
  • RSC 原生支持:完整的 React Server Components、流式渲染、Server Actions 支持

但 vinext 仍处于实验阶段(🚧 Experimental),迁移过程远非一帆风顺 — 本文记录了我们在真实生产项目中遇到的 16 个坑位

项目技术栈

组件技术
框架Next.js App Router (RSC)
运行时Cloudflare Workers (Edge Runtime)
数据库Cloudflare D1 (SQLite)
ORMPrisma + @prisma/adapter-d1
认证better-auth (从 NextAuth v5 迁移)
国际化next-intl (en/zh)
存储Cloudflare R2
缓存Cloudflare KV
包管理pnpm

迁移概览

整个迁移涉及 25 个 commits,修改了 50+ 个文件。问题主要集中在以下几个维度:

构建阶段 ─── Prisma Client 模块解析 (3 次迭代)
          └── Wrangler 配置格式转换

运行时 ───── ESM 导入方式变更
          ├── Proxy 导出签名
          ├── 环境变量访问方式
          └── NextURL 只读属性

框架兼容 ─── Middleware matcher 语法
          ├── notFound() 错误处理
          ├── RSC 条件导出
          ├── .rsc 请求处理
          └── Link 组件 vs 原生 <a>

认证系统 ─── DateInt 类型转换
          ├── OAuth State 清理查询
          ├── VerificationToken 主键
          ├── String ↔ Int ID 转换
          └── emailVerified BooleanInt

第一关:Vite 构建 — Prisma Client 解析

症状pnpm build 失败,报 No such module ".prisma/client/default"

原因:Prisma 生成的客户端使用 .prisma/client/default 作为裸模块标识符 (bare specifier)。虽然它以 . 开头,但并不是相对路径 — Node.js 会从 node_modules 中解析它。Vite 把它当作相对路径处理,找不到模块后将其标记为 external,导致 Workers 运行时报错。

踩坑过程(3 次迭代):

第一次:resolve.alias(失败)

// vite.config.ts — 第一次尝试
export default defineConfig({
  resolve: {
    alias: {
      '.prisma/client/default': resolve(import.meta.dirname, 'node_modules/.prisma/client/wasm.js'),
    },
  },
});

问题import.meta.dirname 在 Vite 编译 TypeScript 配置时,可能解析到临时目录而非项目根目录,导致 CI 环境路径错误。

第二次:process.cwd()(部分成功)

// 改用 process.cwd()
'.prisma/client/default': resolve(
  process.cwd(),
  'node_modules/.prisma/client/wasm.js'
),

问题:在 pnpm 的 store 布局下,.prisma/client/wasm.js 不在 node_modules 直接目录中,ENOENT 错误。

第三次:Vite resolveId 插件(最终方案)

function prismaClientResolve(): Plugin {
  const _require = createRequire(import.meta.url);
  let prismaDir: string | null = null;

  // 策略 1: 项目根目录直接查找
  const directPath = resolve(process.cwd(), 'node_modules', '.prisma', 'client');

  // 策略 2: 从 @prisma/client 包位置反向查找(兼容 pnpm store)
  let pnpmPath: string | null = null;
  try {
    const prismaClientPkg = _require.resolve('@prisma/client/package.json');
    pnpmPath = resolve(dirname(prismaClientPkg), '..', '..', '.prisma', 'client');
  } catch {}

  // 选择第一个存在的路径
  for (const candidate of [directPath, pnpmPath].filter(Boolean) as string[]) {
    if (existsSync(candidate)) {
      prismaDir = candidate;
      break;
    }
  }

  return {
    name: 'prisma-client-resolve',
    enforce: 'pre',
    resolveId(source) {
      if (!source.startsWith('.prisma/client')) return null;
      if (!prismaDir) return null;

      const subpath = source === '.prisma/client' ? '' : source.slice('.prisma/client/'.length);

      if (subpath === 'default' || subpath === '') {
        // 优先使用 wasm.js(Workers WASM 引擎)
        const wasmPath = resolve(prismaDir, 'wasm.js');
        if (existsSync(wasmPath)) return wasmPath;

        // 回退到 default.js
        const defaultPath = resolve(prismaDir, 'default.js');
        if (existsSync(defaultPath)) return defaultPath;
      }
      return null;
    },
  };
}

关键点

  • 使用 createRequire@prisma/client 的实际位置反向定位 .prisma/client
  • existsSync 检查避免路径猜测
  • 优先 wasm.js(Workers 兼容)而非 default.js(Node.js 专用)

第二关:Wrangler 配置 — Pages → Workers 格式转换

症状:部署到 Cloudflare 后,环境变量显示为 "preview" 而非 "production"

原因:vinext 使用 Workers 部署(wrangler deploy),但项目的 wrangler 配置还是 Cloudflare Pages 格式。

修改前(Pages 格式):

pages_build_output_dir = ".vercel/output/static"

修改后(Workers 格式):

main = "dist/server/index.js"
no_bundle = true

# vinext 的构建产物是预打包的,告诉 Wrangler 不要重新用 esbuild 打包
[[rules]]
type = "ESModule"
globs = ["**/*.js", "**/*.mjs"]

[assets]
not_found_handling = "none"
directory = "dist/client"

关键点

  • no_bundle = true 是必须的,否则 Wrangler 会用 esbuild 重新打包 vinext 的构建产物,遇到 .prisma/client/default 外部导入时直接报错
  • [[rules]] ESModule 类型声明让 Wrangler 正确处理 vinext 输出的 ES Module 文件
  • [assets] 替代 pages_build_output_dir,指向 vinext 的客户端构建产物

第三关:Workers 运行时 — ESM 与 Proxy 导出

症状:部署后访问任何页面返回 500,Workers 日志无明显错误

问题 1cloudflare:workers 模块必须用静态 ESM 导入

// ❌ 错误 — CJS 动态导入在 Workers 中不工作
const { env } = require('cloudflare:workers');

// ✅ 正确 — 静态 ESM 导入
import { env as cloudflareEnv } from 'cloudflare:workers';

问题 2:vinext 要求 proxy 使用命名导出而非默认导出

// ❌ 错误 — Next.js middleware 的默认导出
export default function middleware(req) { ... }

// ✅ 正确 — vinext 要求命名导出 { proxy }
export async function proxy(req: NextRequest) { ... }

Vitest 兼容cloudflare:workers 在测试环境中不存在,需要 mock:

// vitest.cloudflare-stub.ts
export const env = {};
export default { env };

// vitest.config.ts
resolve: {
  alias: {
    'cloudflare:workers': resolve(__dirname, 'vitest.cloudflare-stub.ts'),
  },
}

第四关:Middleware → Proxy — matcher 语法不兼容

症状:所有页面返回 404,/en 路径抛出 Error 1101(next-intl locale context 缺失)

原因:vinext 的 matchMiddlewarePath 实现中对 matcher pattern 做了 .replace(/\./g, "\\.") 处理。这会破坏 Next.js 标准的正则表达式 matcher:

// Next.js 标准 matcher(包含正则语法)
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'],
};
// 经过 vinext 的 .replace 后变成:
// /((?!api|_next/static|_next/image|.*\\.\\..*)\\..*)
// 完全无法匹配任何路径 → proxy 永远不执行

解决方案:使用 vinext 支持的 :param 语法:

export const config = {
  matcher: ['/:path*'],
};
// vinext 将 :path* 转换为 (?:.*) → 匹配所有路径

附带说明:在 Workers 中不需要排除 _next/static 等静态资源路径,因为 Cloudflare CDN 在请求到达 Worker 之前就会直接提供静态文件。但为了安全,我们在 proxy 中增加了静态文件扩展名检测:

const STATIC_EXTENSIONS =
  /\.(ico|png|jpg|jpeg|gif|svg|webp|css|js|map|woff2?|ttf|eot|webmanifest|txt|xml|json)$/i;

export async function proxy(req: NextRequest) {
  if (STATIC_EXTENSIONS.test(req.nextUrl.pathname)) {
    return NextResponse.next();
  }
  // ...
}

第五关:next-intl — NextURL.port 只读属性

症状:访问任何页面抛出 TypeError: Cannot set property port which has only a getter

原因next-intlcreateIntlMiddleware 内部会修改 NextURL 对象的 port 属性。在标准 Next.js 中 NextURL.port 是可读写的,但 vinext 的 Workers 运行时将其实现为只读 getter。

解决方案:用轻量级的自定义 i18n 路由处理器替代 createIntlMiddleware

function handleI18nRouting(req: NextRequest): NextResponse {
  const { locales, defaultLocale } = routing;
  const pathname = req.nextUrl.pathname;

  // 路径已包含 locale 前缀,直接通过
  const hasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );
  if (hasLocale) return NextResponse.next();

  // 从 Accept-Language 检测首选 locale
  const acceptLanguage = req.headers.get('accept-language') || '';
  let detectedLocale = defaultLocale;
  for (const locale of locales) {
    if (acceptLanguage.toLowerCase().includes(locale)) {
      detectedLocale = locale;
      break;
    }
  }

  // 使用原生 URL 重定向(避免 NextURL setter 问题)
  const url = new URL(req.url);
  url.pathname = `/${detectedLocale}${pathname}`;
  return NextResponse.redirect(url);
}

关键点:使用 new URL(req.url) 而非 req.nextUrl.clone(),因为后者会触发 NextURL 的 setter 逻辑。


第六关:环境变量 — cloudflare:workers vs process.env

症状:认证功能在本地开发正常,部署后报 CLIENT_ID_AND_SECRET_REQUIRED

原因:通过 wrangler secret put 设置的密钥(如 GOOGLE_CLIENT_IDNEXTAUTH_SECRET),只能通过 cloudflare:workersenv 绑定访问,不会出现在 process.env 中。

// ❌ 在 Workers 中永远是 undefined
const secret = process.env.NEXTAUTH_SECRET;

// ✅ 正确方式
import { env as cloudflareEnv } from 'cloudflare:workers';
const secret = (cloudflareEnv as Record<string, unknown>).NEXTAUTH_SECRET;

最终方案:创建统一的环境变量解析函数:

function getEnvVar(key: string): string | undefined {
  // 优先从 Cloudflare Workers 绑定读取
  const cfEnv = cloudflareEnv as Record<string, unknown>;
  if (cfEnv?.[key]) return String(cfEnv[key]);
  // 回退到 process.env(本地开发、测试环境)
  return process.env[key];
}

第七关:notFound() 异常 — NEXT_NOT_FOUND 未捕获

症状:某些页面间歇性返回 500,日志显示 NEXT_NOT_FOUND 异常

原因:vinext 在错误恢复时会用 undefined params 重新渲染 locale layout。代码中的 notFound()(来自 next/navigation)抛出 NEXT_NOT_FOUND 错误,但 vinext 不会像标准 Next.js 那样捕获它,导致整个 RSC 渲染链崩溃。

// ❌ vinext 中不工作
export default async function LocaleLayout({ params }: Props) {
  const { locale } = await params;
  if (!routing.locales.includes(locale)) {
    notFound(); // 抛出 NEXT_NOT_FOUND → 500
  }
}

// ✅ 回退到默认 locale
export default async function LocaleLayout({ params }: Props) {
  const resolvedParams = await params;
  const locale = routing.locales.includes(resolvedParams?.locale)
    ? resolvedParams.locale
    : routing.defaultLocale;
  // 继续渲染...
}

第八关:NextIntlClientProvider — RSC 条件导出冲突

症状:所有页面路由报 Error 1101,日志显示 headers() is not a function

原因next-intlNextIntlClientProvider 使用了 package.json 的 exports 条件导出。在 vinext 的 RSC 环境中,它解析到了一个异步服务端版本,该版本会调用 headers()(来自 next/headers)。但 vinext 不支持在该上下文中调用 headers()

解决方案:创建本地的 'use client' 包装组件:

// app/[locale]/intl-provider.tsx
'use client';

import { IntlProvider } from 'use-intl';

export function ClientIntlProvider({
  locale,
  messages,
  children,
}: {
  locale: string;
  messages: Record<string, unknown>;
  children: React.ReactNode;
}) {
  return (
    <IntlProvider locale={locale} messages={messages as IntlMessages}>
      {children}
    </IntlProvider>
  );
}

关键点

  • 直接从 use-intl(next-intl 的底层库)导入 IntlProvider
  • 'use client' 指令确保 vinext 将其序列化为客户端引用
  • 不使用 NextIntlClientProvider,避免触发服务端条件导出

第九关:RSC 导航 — .rsc 请求被 Auth 拦截

症状:浏览器前进/后退按钮导致页面空白

原因:vinext 使用 .rsc 后缀进行客户端 RSC payload 请求(例如 /en/dashboard.rsc)。proxy 将这些请求当作普通页面请求处理,检查认证状态后重定向到 /login。但 .rsc 请求期望的是 RSC 流数据,收到重定向后 React 无法解析,导致页面空白。

解决方案:在 proxy 中对 .rsc 请求只做 i18n 路由,跳过 auth 检查:

if (pathname.endsWith('.rsc')) {
  // 去掉 .rsc 后缀检查 i18n 路由
  const cleanPath = pathname.slice(0, -4);
  const hasLocale = locales.some(
    locale => cleanPath.startsWith(`/${locale}/`) || cleanPath === `/${locale}`
  );

  if (hasLocale) return NextResponse.next();

  // 添加 locale 前缀并重定向
  const url = new URL(req.url);
  url.pathname = `/${detectedLocale}${pathname}`;
  return NextResponse.redirect(url);
}

安全性:认证在服务端组件层(getSessionSafe())强制执行,不依赖 proxy。


第十关:Link 组件与 API 路由 — RSC 流损坏

症状:用 <Link> 跳转到 API 端点后,浏览器后退显示原始 JSON 而非页面

原因:Next.js 的 <Link> 组件触发客户端 RSC 导航。API 端点返回 JSON 而非 RSC payload,React 尝试解析 JSON 作为 RSC 流失败,破坏了浏览器历史记录中的 React 根节点。

解决方案:对 API 链接使用原生 <a> 标签:

// ❌ 会触发 RSC 导航
<Link href="/api/health">Health Check</Link>

// ✅ 强制全页面导航
<a href="/api/health" target="_blank" rel="noopener noreferrer">
  Health Check
</a>

第十一关:better-auth — Date ↔ Int 类型不匹配

症状:better-auth 的所有数据库写入操作失败

原因:better-auth 内部所有日期字段使用 JavaScript Date 对象,但我们的 Prisma schema 使用 Int(Unix 时间戳,秒)来兼容 D1/SQLite。Prisma 的 SQLite provider 不会自动做这个转换。

解决方案:创建 Prisma Client Proxy,拦截所有 auth 相关模型的操作:

// lib/db/auth-prisma-proxy.ts
const AUTH_DATE_FIELDS: Record<string, string[]> = {
  user: ['emailVerified', 'createdAt', 'updatedAt'],
  account: ['expiresAt', 'createdAt', 'updatedAt'],
  session: ['expires', 'createdAt', 'updatedAt'],
  verificationToken: ['expires', 'createdAt', 'updatedAt'],
};

function deepConvertInputs(obj: unknown, parentKey?: string): unknown {
  if (obj instanceof Date) return Math.floor(obj.getTime() / 1000);
  if (Array.isArray(obj)) return obj.map(item => deepConvertInputs(item));
  if (obj !== null && typeof obj === 'object') {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
      result[key] = deepConvertInputs(value, key);
    }
    return result;
  }
  return obj;
}

export function createAuthPrismaProxy<T>(prisma: T): T {
  return new Proxy(prisma as object, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      if (typeof prop !== 'string') return value;
      const modelKey = resolveModelKey(prop);
      if (!modelKey) return value;

      // 为 auth 模型的每个方法创建代理
      return new Proxy(value as object, {
        get(modelTarget, methodName, modelReceiver) {
          const method = Reflect.get(modelTarget, methodName, modelReceiver);
          if (typeof method !== 'function') return method;
          if (!ALL_OPS.has(methodName as string)) return method.bind(modelTarget);

          return async function (...args: any[]) {
            // 输入:递归转换 Date → Unix timestamp
            if (args[0] && typeof args[0] === 'object') {
              args[0] = deepConvertInputs(args[0]);
            }
            const result = await method.apply(modelTarget, args);
            // 输出:Unix timestamp → Date(仅已知日期字段)
            return convertOutputDates(result, modelKey);
          };
        },
      });
    },
  }) as T;
}

使用方式:

// lib/auth/index.ts
export const auth = betterAuth({
  database: prismaAdapter(createAuthPrismaProxy(prisma), { provider: 'sqlite' }),
  // ...
});

第十二关:OAuth 状态管理 — deleteMany 中的 Date 未转换

症状:Google OAuth 回调后重定向到 ?error=please_restart_the_process

原因:better-auth 的 OAuth 流程中,findVerificationValue 函数在查找状态后会执行清理操作:

// better-auth 内部代码 (state.mjs)
await adapter.deleteMany({
  model: 'verification',
  where: [{ field: 'expiresAt', operator: 'lt', value: new Date() }],
});

我们的 proxy 第一版只拦截了 createupdatefind 操作,且只转换 datacreate/update 字段中的 Date。deleteManywhere 子句中的 new Date() 没有被转换,导致 SQLite 比较失败。

修复:将拦截范围扩展到所有 Prisma 操作(ALL_OPS),并使用递归的 deepConvertInputs 处理整个 args 树:

const ALL_OPS = new Set([
  'create',
  'createMany',
  'update',
  'updateMany',
  'upsert',
  'delete',
  'deleteMany',
  'findFirst',
  'findUnique',
  'findMany',
  'count',
  'aggregate',
]);

第十三关:VerificationToken — 主键缺失与 ID 类型错误

症状:Google OAuth 回调返回 internal_server_error,Workers 日志显示两个错误

错误 1verificationToken.delete({ where: { id: null } })

原因:VerificationToken 模型的 id 字段定义为 String?(可选),没有设置为主键。配合 generateId: false,better-auth 不会为 VerificationToken 生成 ID,导致删除操作传入 id: null

错误 2user.findFirst({ where: { id: "1" } })

原因:better-auth 内部使用 z.coerce.string() 将所有 ID 转换为字符串。但 Prisma schema 中 User 的 idInt,字符串 "1" 无法匹配整数 1

修复

  1. 数据库迁移 — 重建 verification_tokens 表:
-- 0007_fix_verification_token_pk.sql
CREATE TABLE IF NOT EXISTS verification_tokens_new (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  identifier TEXT NOT NULL,
  token TEXT NOT NULL UNIQUE,
  expires INT NOT NULL,
  created_at INT DEFAULT (strftime('%s', 'now')),
  updated_at INT DEFAULT (strftime('%s', 'now')),
  UNIQUE(identifier, token)
);
INSERT INTO verification_tokens_new (identifier, token, expires, created_at, updated_at)
  SELECT identifier, token, expires, created_at, updated_at FROM verification_tokens;
DROP TABLE verification_tokens;
ALTER TABLE verification_tokens_new RENAME TO verification_tokens;
CREATE INDEX idx_verification_tokens_expires ON verification_tokens(expires);
  1. Prisma schema 更新
model VerificationToken {
  id    Int    @id @default(autoincrement())  // 原来是 String?
  // ...
}
  1. 启用 useNumberId — better-auth 内置的 String ↔ Int ID 转换:
// lib/auth/index.ts
export const auth = betterAuth({
  advanced: {
    database: {
      useNumberId: true, // 替代 generateId: false
    },
  },
});

useNumberId: true 让 better-auth 的适配器层自动做 String → Number(输入)和 Number → String(输出)的 ID 转换。


第十四关:emailVerified — Boolean vs Int

症状:Google OAuth 登录时,创建用户失败

原因:当用户通过 Google OAuth 登录时,better-auth 认为邮箱已被 Google 验证,设置 emailVerified: true(布尔值)。但 schema 中 emailVerifiedInt?(Unix 时间戳)。

修复:在 proxy 的 deepConvertInputs 中添加布尔值 → 时间戳转换:

const BOOLEAN_TO_TIMESTAMP_FIELDS = new Set(['emailVerified']);

function deepConvertInputs(obj: unknown, parentKey?: string): unknown {
  if (obj instanceof Date) return Math.floor(obj.getTime() / 1000);

  // 特定字段的 boolean → timestamp 转换
  if (typeof obj === 'boolean' && parentKey && BOOLEAN_TO_TIMESTAMP_FIELDS.has(parentKey)) {
    return obj ? Math.floor(Date.now() / 1000) : null;
  }

  // ... 递归处理
}

第十五关:D1 迁移 — ALTER TABLE 不支持动态默认值

症状wrangler d1 migrations apply 报错 non-constant default

原因:SQLite 的 ALTER TABLE ADD COLUMN 语句不允许使用非常量默认值(如 strftime('%s', 'now'))。

-- ❌ 错误
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT (strftime('%s', 'now'));

-- ✅ 正确 — 先用常量默认值,再用 UPDATE 回填
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT 0;
UPDATE accounts SET created_at = strftime('%s', 'now') WHERE created_at = 0;

第十六关:CI/CD — Workers 域名包含账户子域

症状:GitHub Actions 部署成功但健康检查失败(HTTP 000)

原因:Cloudflare Workers 的默认域名格式是 <worker-name>.<account-subdomain>.workers.dev,而不是 <worker-name>.workers.dev。CI 配置中的回退 URL 少了账户子域。

# ❌ 错误
DEPLOYMENT_URL="https://my-worker.workers.dev"

# ✅ 正确
DEPLOYMENT_URL="https://my-worker.t-ac5.workers.dev"

最佳实践:在 GitHub Repository Variables 中配置 TEST_DEPLOYMENT_URLPRODUCTION_DEPLOYMENT_URL,不要依赖 hardcoded 回退值。


核心代码清单

迁移后的核心文件结构:

├── vite.config.ts              # Vite 配置 + Prisma resolveId 插件
├── proxy.ts                    # 替代 middleware.ts(i18n + auth + CORS/CSRF)
├── wrangler.toml               # 本地开发配置(Workers 格式)
├── wrangler.test.toml          # 测试环境配置
├── wrangler.prod.toml          # 生产环境配置
├── vitest.cloudflare-stub.ts   # cloudflare:workers 测试 mock
├── lib/
│   ├── auth/
│   │   ├── index.ts            # better-auth 配置 + getEnvVar
│   │   ├── session.ts          # getSessionSafe (RSC 安全包装)
│   │   └── password.ts         # PBKDF2 密码哈希(Edge 兼容)
│   └── db/
│       ├── client.ts           # Prisma 单例 + cloudflare:workers ESM 导入
│       └── auth-prisma-proxy.ts # Date↔Int + Boolean→Int 类型转换代理
├── app/[locale]/
│   └── intl-provider.tsx       # 本地 'use client' IntlProvider 包装
└── migrations/
    └── 0007_fix_verification_token_pk.sql  # VerificationToken 主键修复

总结

从 Next.js 迁移到 vinext 不是简单的"换个构建工具"那么简单。主要挑战来自三个层面:

  1. Vite vs webpack 的模块解析差异:Prisma 的裸模块标识符、条件导出的解析优先级等,在两个构建系统中行为完全不同。

  2. Workers vs Node.js 运行时差异process.env 不可用、NextURL 属性只读、notFound() 未被捕获 — 这些都是 Workers 运行时的限制。

  3. 第三方库假设:next-intl 假设 NextURL.port 可写,better-auth 假设日期字段是 Date 对象 — 这些假设在标准 Next.js 中成立,但在 vinext 的 Workers 环境中全部失效。

核心教训:vinext 不是 Next.js 的替代品,而是 Next.js API 在 Workers 运行时上的重新实现。任何依赖 Next.js 实现细节(而非公开 API)的代码,都可能需要适配。

本文基于 2026 年 2 月的实际迁移经验,vinext 仍在快速迭代中,部分问题可能在后续版本中得到解决。