一次真实项目从 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) |
| ORM | Prisma + @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>
认证系统 ─── Date ↔ Int 类型转换
├── OAuth State 清理查询
├── VerificationToken 主键
├── String ↔ Int ID 转换
└── emailVerified Boolean → Int
第一关: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 日志无明显错误
问题 1:cloudflare: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-intl 的 createIntlMiddleware 内部会修改 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_ID、NEXTAUTH_SECRET),只能通过 cloudflare:workers 的 env 绑定访问,不会出现在 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-intl 的 NextIntlClientProvider 使用了 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 第一版只拦截了 create、update、find 操作,且只转换 data 和 create/update 字段中的 Date。deleteMany 的 where 子句中的 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 日志显示两个错误
错误 1:verificationToken.delete({ where: { id: null } })
原因:VerificationToken 模型的 id 字段定义为 String?(可选),没有设置为主键。配合 generateId: false,better-auth 不会为 VerificationToken 生成 ID,导致删除操作传入 id: null。
错误 2:user.findFirst({ where: { id: "1" } })
原因:better-auth 内部使用 z.coerce.string() 将所有 ID 转换为字符串。但 Prisma schema 中 User 的 id 是 Int,字符串 "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);
- Prisma schema 更新:
model VerificationToken {
id Int @id @default(autoincrement()) // 原来是 String?
// ...
}
- 启用
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 中 emailVerified 是 Int?(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_URL 和 PRODUCTION_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 不是简单的"换个构建工具"那么简单。主要挑战来自三个层面:
-
Vite vs webpack 的模块解析差异:Prisma 的裸模块标识符、条件导出的解析优先级等,在两个构建系统中行为完全不同。
-
Workers vs Node.js 运行时差异:
process.env不可用、NextURL属性只读、notFound()未被捕获 — 这些都是 Workers 运行时的限制。 -
第三方库假设:next-intl 假设
NextURL.port可写,better-auth 假设日期字段是 Date 对象 — 这些假设在标准 Next.js 中成立,但在 vinext 的 Workers 环境中全部失效。
核心教训:vinext 不是 Next.js 的替代品,而是 Next.js API 在 Workers 运行时上的重新实现。任何依赖 Next.js 实现细节(而非公开 API)的代码,都可能需要适配。
本文基于 2026 年 2 月的实际迁移经验,vinext 仍在快速迭代中,部分问题可能在后续版本中得到解决。