96SEO 2026-08-01 19:21 4
这篇文章基于开源项目 edge-next-starter 的真实迁移经验撰写。

edge-next-starter 是一个面向 Cloudflare 全栈开发的 Next.js 生产级启动模板。集成了 D1 数据库、R2 对象存储、KV 缓存、娱乐ter‑auth 认证、next‑intl 国际化、Stripe 支付等公司级能力,开箱即用。项目采用 Repository 模式 + Edge Runtime 架构设计,所有代码均运行在 Cloudflare Workers 全球边缘网络上。
GitHub: ⭐ 欢迎 Star
Cloudflare 发布了一篇震动 Web 开发社区的博客:《How we rebuilt Next.js with AI in one week》。一名 Cloudflare 工程师 Steve Faulkner 使用 Anthropic Claude。仅花费约 $200 的 token 费用,在一周内从零重建了 Next.js 的主要 API。产物就是 vinext——一个基于 Vite 建立的 Next.js 替代实现,专门针对 Cloudflare Workers 调整。
Dane Knecht称之为「Next.js 的解放日」。项目大部分代码由 AI 编写,人类负责架构方向、优先级和设计决策。不过,
@cloudflare/next-on-pages/OpenNext 需要逆向工程 Next.js 输出,一键部署难度大。老实说,vite dev 在 Workers Runtime 中运行,可直接调试 D1、KV、Durable Objects 等网站 API。*注意:vinext 仍处于实验阶段,迁移过程远非一帆风顺 — 这篇文章记录了我们在真实生产项目中遇到的 15+ 坑位。按理说,
| 组件 | 技术选型 |
|---|---|
| 框架 | Next.js App Router |
| 运行时 | Cloudflare Workers |
| 数据库 | D1 |
| Prisma + @prisma/adapter-d1 | |
| 认证 | 娱乐ter‑auth |
| 国际化 | next‑intl | 存储 | R2 + KV | 包管理 | pnpm |
The migration spans 12 commits。touching +30 files.
# 建立阶段
├─ Prisma Client 模块解析
└─ Wrangler 配置格式转换
# 运行时
├─ ESM 导入方式变更
├─ Proxy 导出签名
├─ 环境变量访问方式
└─ NextURL 只读属性
# 框架兼容
├─ Middleware matcher 语法
├─ notFound 错误处理
├─ RSC 条件导出 & .rsc 请求处理
└─ Link 组件 vs 原生
# 认证程序
├─ Date ↔ Int 类型转换
├─ OAuth State 清理查询
├─ VerificationToken 主键缺失
├─ String ↔ Int ID 转换
└─ emailVerified Boolean → Int
#…其他细节省略,怎么说呢,
报错 “No such module ".prisma/client/default"”。.prisma/client/default. Node 会从 中解析。而 Vite 把它当相对方法处理并标记为 external,导致 Workers runtime 找不到模块。
// vite.config.ts — 第一次尝试
export default defineConfig({
resolve: {
再看alias。{
'.prisma/client/default': resolve,},},});其实,
在 Vite 编译 TypeScript 配置时指向临时目录,CI 环境方法错误。
// 改用 process.cwd
'.prisma/client/default': resolve(
process.cwd,'node_modules/.prisma/client/wasm.js'
);
// vite.config.ts – prismaClientResolve 插件
function prismaClientResolve: Plugin {
const _require = createRequire;let prismaDir: string | null = null;// 策略 A – 项目根目录直查
const directPath = resolve。'node_modules','.prisma','client');// 策略 B – pnpm store 倒推方法
let pnpmPath: string | null = null;try {
const pkg = _require.resolve;pnpmPath = resolve,'..'。'..','.prisma','client');} catch {}
// 挑选第一个存在的方法
for as string) {
if ) {
prismaDir = candidate;break,}
}
return {
再看name,'prisma-client-resolve'。enforce: 'pre',resolveId {
if ) return null;if return null;const subpath = source === '.prisma/client'?'' : source.slice;if {
const wasmPath = resolve;if ) return wasmPath;const defaultPath = resolve;if ) return defaultPath;}
return null;},},话说回来,}
S关键点:
-
A 使用
定位实际 @prisma/client 包位置。以兼容 pnpm 工作区。
-
B 用
防止方法猜测错误。C 优先返回 `wasm.js`,回退 `default.js`。至于第二关,Wrangler 配置转换 – Pages ➜ Workers
说到痛点。环境变量始终显示 “preview” 而非 “production” ⚠️"
-
S 症状:部署后 `process.env.NODE_ENV` 为 `"preview"`;自定义 secret 未生效。wrangler deploy
输出显示 `environment: preview`.
**S原因**:旧项目仍保留 Cloudflare **Pages** 格式配置;话说回来,而 vinext 必须使用 **Workers** 格式。否则 Wrangler 会把 `process.env` 注入方式当作 Pages 并覆盖。toml
# ❌ Pages 格式示例
pages_build_output_dir = ".vercel/output/static"
**S正确做法**:
toml
# ✅ Workers 格式示例
main = "dist/server/index.js"
no_bundle = true # 告诉 Wrangler 不再二次打包 vinext 输出
]
type = "ESModule"
globs =
directory = "dist/client"
not_found_handling = "none"
S关键点:
-
`no_bundle=true` 必不可少,否则 esbuild 会重新打包导致 `.prisma/...`
报错。
-
`]` 声明 ESModule,让 Wrangler 正确处理 Vite 输出的 `.js` / `.mjs` 文件。
-
`` 替代 `pages_build_output_dir` 指向 vinext 客户端产物目录。---
### 第三关:Workers Runtime – ESM 与 Proxy 导出
再看痛点。页面返回空白 `
` 而无日志 ❓
至于**症状**,* 部署后访问任何路由,只返回空白 HTML `
`。* Worker 日志里没有错误堆栈。至于**根因**,1. **ESM 动态导入失效**——在 Workers 中只能使用静态 ESM。ts
// ❌ CJS 动态导入会在 Worker 环境报错但不抛异常
const { env } = require;改为:
ts
import { env as cfEnv } from 'cloudflare:workers';2. **Proxy 必须命名导出**——vinext 不再接受默认导出的 middleware。ts
// ❌ 默认导出 - 不被识别
export default function middleware {...}
// ✅ 命名导出 - 被 vinext 正确路由到 proxy
export async function proxy {...}
**Vitest 测试中的 Mock**:
ts
// vitest.cloudflare-stub.ts
export const env = {};export default { env };ts
// vitest.config.ts
resolve: {
说到alias,{
'cloudflare:workers': resolve
}
}
---
### 第四关:Middleware ➜ Proxy – Matcher语法不兼容
说到痛点。Locale 路由失效,所有页面报 `Error: next-intl locale context missing`
**症状**的观点是,* 所有页面返回空白 `
`。老实说,* `/en` 方法抛出 “locale context missing”。**原因分析**:
Vinex 的 `matchMiddlewarePath` 对 matcher 做了 `.replace` 转义,这破坏了原生正则表达式 matcher。ts
// Next.js 标准 matcher
export const config = {
matcher:,};经过 Vinex 转义后变成无法匹配任何方法。其实,说到**方法**,改用 Vinex 支持的 **`:param` 通配符语法**
ts
export const config = {
matcher:,};怎么说呢,Vinex 将 `:path* → ` 自动匹配所有请求。对了在 Worker 中不必手动排除 `_next/static` 等静态资源,因为 CDN 已在 Edge 前拦截;怎么说呢,但为了安全仍可在 proxy 中加入文件
再看名检测。ts
const STATIC_EXTENSIONS = /\.$/i;if ) return NextResponse.next;---
### 第五关:next‑intl – NextURL.port只读属性
再看痛点,启动时报错 `TypeError: Cannot set property port which has only a getter`
**根因**的观点是。`next-intl.createIntlMiddleware` 会尝试修改 `NextURL.port`;在 Vinex 的 Worker 实现里该属性是只读 getter。**快速替代方案**:
自行实现轻量级 i18n 重定向,不依赖 `createIntlMiddleware`。ts
function handleI18nRouting: NextResponse {
const { locales,defaultLocale } = routing;const pathname = req.nextUrl.pathname;const hasLocale = locales.some);
if return NextResponse.next;const acceptLang=req.headers.get||'';let detected=defaultLocale;for{
if.includes){detected=l;break,}
}
const url=new URL;url.pathname=`/${detected}${pathname}`;return NextResponse.redirect;}
关键点是 **使用原生 `new URL` 而非 `req.nextUrl.clone`**,避免触发只读属性 setter。---
### 第六关:环境变量访问 – cloudflare:workers vs process.env
从痛点来看。部署后 Auth 报错 `CLIENT_ID_AND_SECRET_REQUIRED`
说到**根因**,通过 `wrangler secret put` 设置的密钥只能通过 **Workers 环境对象 ** 获取;怎么说呢,它们不会自动映射到 Node 的全局 `process.env`.
ts
// ❌ 永远 undefined
const secret=process.env.NEXTAUTH_SECRET;// ✅ 正确读取方式
import { env as cfEnv } from 'cloudflare:workers';const secret=cfEnv.NEXTAUTH_SECRET as string;为兼容本地开发与 CI,我们封装统一读取函数:
ts
function getEnvVar:string|undefined{
const cfEnv=cfEnv as Record
;ifreturn String;return process.env;}
所有业务代码改为调用 `getEnvVar`.
---
### 第七关:notFound 异常未捕获
至于痛点,间歇性页面返回 HTTP 500。日志显示 `NEXT_NOT_FOUND`
Vinex 在错误恢复层不会像标准 Next.js 那样捕获由 `notFound` 抛出的特殊异常,从而导致 RSC 渲染链崩溃。说起来,#### 修复思路
在布局层手动做 **fallback 到默认 locale**。而不是直接调用 `notFound`:
tsx
export default async function LocaleLayout{
const resolved=await params;不过,let locale=resolving.locale;if){
locale=routing.defaultLocale;}
// 接下来正常渲染...
}
---
### 第八关:NextIntlClientProvider 条件导出冲突
再看痛点。所有页面报错 `headers is not a function`
原因是 **next‑intl 的 package.json exports 条件导出**,在 Vinex RSC 环境下解析到了服务端版本,该版本会调用仅在 Edge API 中可用的 `headers`。话说回来,#### 替代实现
创建纯客户端包装组件并强制 `'use client'`:
tsx
// app//intl-provider.tsx
'use client';import { IntlProvider } from 'use-intl';export function ClientIntlProvider({
locale。messages,children,}:{
再看locale,string;messages:Record;children:React.ReactNode;}){
return (
{children}
);}
随后在布局中引用该组件即可绕过服务端条件导出。---
### 第九关:“.rsc” 请求被 Auth 拦截
从痛点来看,前进/后退按钮导致页面空白
Vinex 使用 `.rsc` 后缀请求 React Server Component payload。当 proxy 对这些请求执行普通 auth 重定向时React 收到的是 HTTP302 而非流数据,于是渲染失败。#### 修复策略
在 proxy 中对 `.rsc` 请求仅执行 **i18n 路由**,跳过 auth 检查:
ts
if){
const clean=pathname.slice;const hasLocale=locales.some||clean===`/${l}`);不过,ifreturn NextResponse.next;// 否则添加默认 locale 并重定向:
const url=new URL;url.pathname=`/${detectedLocale}${pathname}`;return NextResponse.redirect;}
真正的身份校验仍然放在 RSC 层 `) 完成。---
### 第十关:Link 与 API 路由冲突
说到痛点,点击 `` 后浏览器历史记录里出现 JSON 响应
原因是 **Next Link 会触发 RSC 导航**。而 API 返回的是普通 JSON,不符合 RSC 流格式,从而破坏 React 根节点。#### 推荐做法
对所有指向 API 或下载文件的链接使用原生 ``:
tsx jsx
{/* ❌ 会触发 RSC */}
Health Check
/* ✅ 强制全页面导航 */
Health Check
;---
### 第十一关:娱乐ter‑auth 日期字段类型冲突
痛点这方面,写入 D1 时全部报错 “Invalid type for column”
默认 schema 将日期字段声明为 **Int ** 来适配 SQLite。但 娱乐ter‑auth 内部只接受 **JavaScript Date 对象**。#### Prisma Proxy 实现
创建代理层统一把 Date ⇄ Unix Timestamp 双向转换,并兼容 Boolean → Timestamp 场景。从主要代码摘录来看,ts
// lib/db/auth-prisma-proxy.ts
const DATE_FIELDS=new Set;function deepConvertInputs:any{
if return Math.floor/1000);if)
return obj?Math.floor/1000):null;if)return obj.map);if{
const res:{}={};for){
res=deepConvertInputs;}
return res;按理说,}
return obj;}
// Proxy wrapper -------------------------------------------------
export function createAuthPrismaProxy:T{
return new Proxy(client as any,{
get{
const val=Reflect.get;ifreturn val;// 为每个模型创建子代理…,}
});}
再看使用方式,ts
import { createAuthPrismaProxy } from '@/lib/db/auth-prisma-proxy';import prisma from '@/lib/db/client';export const auth=娱乐terAuth({
database:createAuthPrismaProxy,// …其他配置,});---
### 第十二关:OAuth State 清理未转化 Date → Unix 时间戳
至于痛点,“error=please_restart__process”
OAuth 流程中调用了 `deleteMany}}})`;按理说,我们之前的代理只拦截了 CRUD 操作。没有覆盖 **where 子句中的 Date 参数**,导致 SQLite 比较失败。#### 完整拦截
将代理范围扩大至所有 Prisma 操作 并递归处理整个参数树,即可解决此类隐藏日期字段问题。---
### 第十三关:VerificationToken 主键缺失 & ID 类型错误
再看痛点,“internal_server_error”,日志分别提示 “id is null” 与 “String cannot be cast to Int”
#### 修复步骤
1️⃣ **数据库迁移**
sql
-- 重建 verification_tokens 表并设主键自增
CREATE TABLE 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 ),updated_at INT DEFAULT ),UNIQUE
);INSERT INTO verification_tokens_new
SELECT ... 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;2️⃣ **Prisma schema 更新**
prisma
model VerificationToken {
id Int @id @default)
identifier String @unique
token String @unique
expires Int // Unix timestamp 秒数
created_at Int?updated_at Int?}
3️⃣ **启用 娱乐ter‑auth ID 自动转型**
ts
export const auth=娱乐terAuth({
advanced:{
database:{ useNumberId:true } // 自动把 String ↔ Number 转换
},});不过,这样既解决了删除时传入 `{id:null}` 的问题。也让 User.id 从 Int ↔ String 双向兼容。---
### 第十四关:emailVerified 布尔值 vs Unix Timestamp
说到痛点,“User creation failed” 因字段类型不匹配
Better‑auth 在 OAuth 成功后会设置 `{emailVerified:true}`。我们的 schema 将其定义为 *Int?*,导致插入失败,话说回来,#### 补丁
在前面的 *deepConvertInputs* 中加入布尔 → 时间戳映射:
ts
if{
return obj?Math.floor/1000):null;话说回来,}
这样就能顺利写入 Unix 时间戳或保持为空值。---
### 第十五关:D1 ALTER TABLE 动态默认值限制
痛点这方面,“non-constant default” 错误阻塞迁移脚本执行
SQLite 不允许在 ALTER TABLE 时使用函数作为默认值。例如 `)`.
#### 两步走方法
1️⃣ 添加列时使用常量默认值:
sql
ALTER TABLE accounts ADD COLUMN created_at INT DEFAULT 0;2️⃣ 再用 UPDATE 填充真实时间:
sql
UPDATE accounts SET created_at=strftime WHERE created_at=0;同理对其它表格新增时间列均采用此模式即可顺利完成 D1 migration。---
### 第十六关:CI/CD 域名配置错误
再看痛点,“健康检查 HTTP502”。日志显示无法连接目标域名
Worker 默认域名形如 `.YOUR_ACCOUNT_SUBDOMAIN.workers.dev`. 项目 CI 脚本中硬编码了省略子域名的 URL,导致请求落到不存在的入口。话说回来,#### 正确做法
在 GitHub Repository Variables 中分别设置:
env
TEST_DEPLOYMENT_URL=https://my-worker.t-ac5.workers.dev # 测试环境
PRODUCTION_DEPLOYMENT_URL=https://my-worker.t-ac5.workers.dev # 正式环境
CI 步骤引用对应变量。而不是硬编码 `"https://my-worker.workers.dev"`。---
主要代码清单 & 项目结构 📂
|--- vite.config.ts # Vite 配置 + Prisma resolveId 插件
|--- proxy.ts # 替代 middleware.ts
|--- wrangler.toml # 本地开发配置
|--- wrangler.test.toml # 测试环境配置
|--- wrangler.prod.toml # 生产环境配置
|--- vitest.cloudflare-stub.ts # cloudflare:workers 测试 mock
|
|--- lib/
│ ├── auth/
│ │ ├── index.ts # 娱乐ter-auth 配置 + getEnvVar
│ │ ├── session.ts # getSessionSafe
│ │ └── password.ts # PBKDF2 密码哈希
│ └── db/
│ ├── client.ts # Prisma 单例 + ESM 导入
│ └── auth-prisma-proxy.ts# Date↔Int + Boolean→Int 类型转换代理
|
|--- app//
│ └── intl-provider.tsx # 本地 ‘use client’ IntlProvider 包装
|
└--- migrations/
└── 0007_fix_verification_token_pk.sql # VerificationToken 主键修复
& 主要教训 🎯
Migrating from **Next.js on Pages** to **vinext on Workers** 涉及三个主要维度:
-
建立程序差异:Lack of compatibility 娱乐ween Turbopack/Webpack and Vite leads to module resolution issues such as Prisma's bare specifier.
-
Runtime 差异:The Worker environment lacks Node globals。forces static ESM imports and changes error handling semantics . These differences surface as invisible runtime failures.
-
第三方库假设破裂:Libraries built for classic Node‑based Next.js assume mutable URLs,direct access to request headers via server actions,and specific export patterns. When running under vinext y silently break unless you replace or shim m.
主要警示:
-
Avoid assuming that any Node-specific APIs are available inside Workers.
-
If you rely on third‑party packages that touch URLs or environment variables—wrap m in adapters early in migration.
-
Pinned versions of Vinex and its plugins save you from sudden breaking changes.
Once adapter layer is stable—future migrations become trivial.
这篇文章基于2024年10月实际迁移经验撰写,Vinex仍在快速迭代中,部分问题可能已在后续版本中得到官方修复。怎么说呢,如有新坑位欢迎提交 Issue 共创更完整的搬家教程!其实,🛠️🚀
作为专业的SEO优化服务提供商,我们致力于通过科学、系统的搜索引擎优化策略,帮助企业在百度、Google等搜索引擎中获得更高的排名和流量。我们的服务涵盖网站结构优化、内容优化、技术SEO和链接建设等多个维度。
| 服务项目 | 基础套餐 | 标准套餐 | 高级定制 |
|---|---|---|---|
| 关键词优化数量 | 10-20个核心词 | 30-50个核心词+长尾词 | 80-150个全方位覆盖 |
| 内容优化 | 基础页面优化 | 全站内容优化+每月5篇原创 | 个性化内容策略+每月15篇原创 |
| 技术SEO | 基本技术检查 | 全面技术优化+移动适配 | 深度技术重构+性能优化 |
| 外链建设 | 每月5-10条 | 每月20-30条高质量外链 | 每月50+条多渠道外链 |
| 数据报告 | 月度基础报告 | 双周详细报告+分析 | 每周深度报告+策略调整 |
| 效果保障 | 3-6个月见效 | 2-4个月见效 | 1-3个月快速见效 |
我们的SEO优化服务遵循科学严谨的流程,确保每一步都基于数据分析和行业最佳实践:
全面检测网站技术问题、内容质量、竞争对手情况,制定个性化优化方案。
基于用户搜索意图和商业目标,制定全面的关键词矩阵和布局策略。
解决网站技术问题,优化网站结构,提升页面速度和移动端体验。
创作高质量原创内容,优化现有页面,建立内容更新机制。
获取高质量外部链接,建立品牌在线影响力,提升网站权威度。
持续监控排名、流量和转化数据,根据效果调整优化策略。
基于我们服务的客户数据统计,平均优化效果如下:
我们坚信,真正的SEO优化不仅仅是追求排名,而是通过提供优质内容、优化用户体验、建立网站权威,最终实现可持续的业务增长。我们的目标是与客户建立长期合作关系,共同成长。
Demand feedback