- 五阶段迁移路径(tsconfig / 根 layout → 路由 → 数据获取 → 资源与环境变量 → 构建工具),可以按阶段增量交付,不必一次性重写。
- Next.js 官方 Migrating from Vite 指南建议第一步先以
output: 'export' 跑成 SPA 再逐步升级,能把风险和 diff 都压到最小。
- 真实案例:一位开发者把作品集从 Vite 迁到 Next.js 后,JS Bundle 从 185 KB 降到 84 KB(-54%),Lighthouse 性能分 78 → 96,总共花了约 15 小时。
- 并非所有项目都值得迁。纯内网 Dashboard、无 SEO 需求的管理后台,留在 Vite 通常仍是更简单的选择。
- Vitest 可以继续用于 Next.js,但要走官方推荐的 Vitest 集成方式,或者迁到 Jest +
next/jest 才能稳定覆盖 RSC 组件。
- Turbopack 从 Next.js 15 起是默认 dev 构建器,Vite 用户在 HMR 体验上不会退步,冷启动一般在 3–5 秒之间。
为什么在 2026 年考虑从 Vite 迁到 Next.js
说实话,2026 年上半年 React 生态里最明显的变化,是 Create React App 已经彻底退休,而 Meta 的官方指引把新项目分成了两类:需要 SEO / SSR 就用 Next.js(或 React Router 7 Framework Mode),纯 SPA 用 Vite。这条分界线让很多 3–4 年前用 Vite 起手的团队重新面对同一个问题:随着业务里 marketing 页面、博客、着陆页越来越多,Vite 的 CSR-only 模型开始拖后腿。
Vite 官方从来不解决三件事:首屏白屏、爬虫索引、边缘缓存。你可以用 vite-plugin-ssr(现在改名叫 Vike)自己拼一套,但那基本等于自建一个 mini Next.js。在我给客户做的三次迁移评估里,触发迁移决策的最直接指标一直是这几个:Lighthouse 上 LCP > 2.5s、Google Search Console 里 "已发现 – 尚未编入索引" 的页面超过 10 条、或者产品经理开始问 "为什么 Twitter 卡片抓不到我们的 OG 图"。(第三种最常见。)
另一层驱动来自架构。如果你的 Vite 前端后面挂着一个 Express/Fastify 服务,Next.js 15 的 Server Components + Server Actions 组合几乎可以让你把 REST/BFF 那一层直接删掉。我在两个中等项目里验证过:迁移后 Express 那侧的路由数量分别从 47 条减到 12 条、从 63 条减到 9 条,剩下的都是纯粹的 webhook 和第三方回调。想深入了解替代方案,可以先看看 Next.js Server Actions 完全指南 里的原理章节,那能帮你判断哪些 Express 路由是 "真的需要保留" 的。
迁移前的 30 分钟判断:值不值得
在写下第一行代码前,用下面这张对比表花 30 分钟做一次结论式判断,比之后返工要便宜得多。这不是 "哪个框架更好" 的问题,而是 "你现在这个项目,迁移收益能不能 cover 掉迁移成本" 的问题。
| 维度 | Vite + React(保留) | Next.js 15 App Router(迁移) |
| 首屏渲染模型 | CSR,白屏 1.5–3 秒 | SSR / SSG / PPR,可控到 0.6–1.2 秒 |
| SEO 友好度 | 需自建 SSR 层,成本高 | 开箱即用,内置 metadata / sitemap / OG |
| 数据获取模式 | 客户端 fetch + 独立 API 层 | Server Components 直连数据库、Server Actions 处理 mutation |
| 路由 | React Router 7 声明式 | 文件系统 + 嵌套 layout + 并行/拦截路由 |
| 构建速度(dev) | Vite 冷启动 1–2 秒 | Turbopack 冷启动 3–5 秒 |
| 构建速度(生产) | Rollup / Rolldown | Turbopack 生产模式(Next.js 15.3 起 stable) |
| 部署 | 静态托管(CF Pages / Netlify / S3) | Vercel / self-host Node / Docker / edge |
| 学习曲线 | 低,纯 React 心智模型 | 中,需要理解 RSC / Server 边界 / 缓存 |
我个人的经验规则很直接:如果表格里 "首屏渲染 + SEO + 数据获取" 这三行里任何一条对业务重要,迁到 Next.js 的回报几乎一定为正;如果三条都不重要,那你现在的 Vite 项目基本就是终点,别折腾。别为了 "跟上潮流" 迁。那笔工时最后会以别的形式还回来。
五阶段 Playbook 与工时估算
为什么用五阶段,而不是官方文档里的十几步?因为在做排期的时候,五阶段的粒度正好对应一个人一天到一周的可交付单位。下面是我在三个项目里实际记录下来的时长分布,供你排 sprint 时参考。
| 阶段 | 作品集 / 小站 | 中等 SaaS 前端 | 大型项目(含 Express 后端) |
| 1. tsconfig + 项目骨架 | 1–2 h | 0.5–1 d | 1–2 d |
| 2. React Router → App Router | 4–6 h | 2–4 d | 1–2 周 |
| 3. 数据获取 / 拆 Express | 2–3 h | 2–3 d | 1–3 周 |
| 4. 资源、字体、环境变量 | 2–3 h | 1 d | 2–3 d |
| 5. 测试 + Turbopack | 2–4 h | 1–2 d | 3–5 d |
| 总计 | ≈ 15 h / 3 d | 7–11 d | 3–8 周 |
阶段 1:TypeScript 与项目骨架
这一阶段的目标是让 Next.js 能启动,页面能空跑一次,不追求功能对齐。核心动作有四个:新增 next.config.ts、扩展 tsconfig.json、写一个最小 app/layout.tsx、加一个 catch-all [[...slug]]/page.tsx 接管所有旧路由。这也是官方 "先跑成 SPA 再增量升级" 心法的落地方式。
// next.config.ts 迁移过渡期先跑成 SPA
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 关键:先保留 SPA 语义,逐路由再切 SSR
output: 'export',
distDir: 'build',
images: {
// export 模式下 next/image 需要 unoptimized;阶段 4 会移除
unoptimized: true,
},
// Vite 里常见的路径别名,等价迁移
webpack: (config) => {
config.resolve.alias['@'] = new URL('./src', import.meta.url).pathname
return config
},
}
export default nextConfig
// tsconfig.json 关键增量
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "preserve",
"moduleResolution": "bundler",
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
// src/app/layout.tsx 把 index.html 里的 head/body 搬进来
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'My App',
description: 'Migrated from Vite',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body><div id="root">{children}</div></body>
</html>
)
}
// src/app/[[...slug]]/page.tsx 让旧的 React Router 暂时接管
'use client'
import dynamic from 'next/dynamic'
const App = dynamic(() => import('../../App'), { ssr: false })
export default function CatchAll() { return <App /> }
做完这一步跑 next dev,你的旧站应该几乎原封不动地跑起来。这就是官方文档反复强调的 "incremental migration" 心法:先让它跑,再一路一路切成 Server Component。
阶段 2:从 React Router 迁到 App Router
这一阶段是整个 Playbook 里工时占比最大的一块,也是最容易低估的一块。React Router 是声明式的 <Route path="/blog/:slug" element={<Post />} />,App Router 是文件系统:app/blog/[slug]/page.tsx。字面上是 1:1 的替换,麻烦的是 layout、loader、outlet 这三样在两个模型里语义不同。
路由 API 映射对照
<Outlet /> → app/some-path/layout.tsx 里的 {children}
useNavigate() → useRouter()(next/navigation)
useParams() → App Router 里 page.tsx 的 props.params 是 Promise,需要 await
useSearchParams() → 仍然叫这个名字,但从 next/navigation 导入,且需要 <Suspense> 包裹
Link → next/link,属性由 to 改为 href
典型页面重写
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPost, getAllPostSlugs } from '@/lib/posts'
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}
// 让 Next.js 在 build 时静态生成
export async function generateStaticParams() {
const slugs = await getAllPostSlugs()
return slugs.map((slug) => ({ slug }))
}
Layout 的迁移一般更容易。React Router 里 Layout > Outlet 的结构,直接对应 app/(marketing)/layout.tsx 里的 {children}。用 route group(括号命名的目录)来划分 marketing、app、admin 三块布局,是我常用的组织习惯。如果你的路由里有模态框、分栏视图,可以参考 Next.js Parallel Routes 与 Intercepting Routes 完全指南 里的模式表。
阶段 3:数据获取与拆掉 Express
Vite 项目里的数据获取通常是 useEffect + fetch + React Query 或 SWR 的三件套。迁到 Next.js 之后,你能选的路径其实是三条,而且这三条不是替代关系,而是共存的:
- Server Components 里直接 fetch / 查库:适合列表页、详情页这种 "打开就要数据" 的 SSR 场景。
- Server Actions:适合表单提交、乐观更新这类 mutation。用
<form action={updatePost}> 就可以,不用再写 API 路由。
- Route Handlers:适合被第三方或客户端 SDK 消费的 REST/JSON 端点,比如 webhook、图片上传回调。详细模式可以看 Next.js Route Handlers 完全指南。
// app/dashboard/orders/page.tsx RSC 直接查库
import { db } from '@/lib/db'
import { ordersTable } from '@/lib/schema'
import { desc } from 'drizzle-orm'
export default async function OrdersPage() {
const orders = await db
.select()
.from(ordersTable)
.orderBy(desc(ordersTable.createdAt))
.limit(50)
return (
<table>
<tbody>
{orders.map((o) => (
<tr key={o.id}><td>{o.total}</td></tr>
))}
</tbody>
</table>
)
}
// app/dashboard/orders/actions.ts 用 Server Action 替换旧的 PATCH /api/orders
'use server'
import { revalidatePath } from 'next/cache'
import { eq } from 'drizzle-orm'
import { db } from '@/lib/db'
import { ordersTable } from '@/lib/schema'
export async function markShipped(formData: FormData) {
const id = String(formData.get('id'))
await db.update(ordersTable).set({ status: 'shipped' }).where(eq(ordersTable.id, id))
revalidatePath('/dashboard/orders')
}
拆 Express 的时候,我一般先列一张表:每一条现有 API 路由属于 "RSC 场景"、"Server Action 场景" 还是 "真正的 Route Handler 场景"。前两类可以直接删;只有 webhook、需要非 cookie 鉴权的开放 API、以及 CLI/移动端调用的接口需要留下来做 Route Handler。想理解 fetch 缓存与新的 use cache 指令,可以看 Next.js 数据获取与缓存策略完全指南。
阶段 4:图片、字体、环境变量
这是 Vite 用户最容易踩坑的阶段,因为几乎所有前缀、导入方式和优化路径都变了。三件事分别搞定。
1. 环境变量:VITE_ → NEXT_PUBLIC_
Vite 里以 VITE_ 开头的变量会注入到客户端 bundle,Next.js 里对应的是 NEXT_PUBLIC_。所有需要在浏览器读取的变量都要重命名前缀;只在服务端使用的(数据库连接串、私钥)保持不加前缀。这也是 Next.js 相对于 Vite 更安全的一点:服务端变量默认不会泄漏进客户端 bundle。(我曾经在一次上线前 15 分钟发现一个前实习生把 Stripe secret 加了 NEXT_PUBLIC_ 前缀,那口气差点没缓过来。)
# .env.local
# Vite 时代
VITE_PUBLIC_ANALYTICS_ID=UA-xxx
# Next.js 时代:只重命名,值不变
NEXT_PUBLIC_ANALYTICS_ID=UA-xxx
# 服务端专用:不加前缀
DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...
2. 图片:<img> → next/image
迁移之初你可以先保留原生 <img>,别为了迁而迁。等阶段 1 里的 output: 'export' 移除、切到默认 SSR 模式之后,再把 hero 图、封面图、卡片图这些 LCP 相关的资源换成 next/image。next/image 自动提供的 srcset、AVIF/WebP、blur 占位,是 Vite 里要靠 vite-plugin-image-optimizer 自己拼的。
import Image from 'next/image'
import cover from '@/assets/cover.png'
<Image src={cover} alt="文章封面" priority />
// 静态 import 时 width/height 会被自动推断;priority 用于 LCP 元素
3. 字体:next/font 取代 @font-face
next/font/google 与 next/font/local 在构建时把字体转成自托管资源,避免 Google Fonts 的额外 DNS/TLS 往返。这一步几乎是纯收益,能把 FCP 再压 200–400 ms。
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html className={inter.className}><body>{children}</body></html>
}
阶段 5:测试与 Turbopack 构建
最后一步是把开发/测试/构建工具链切齐。Next.js 15 起 next dev 默认使用 Turbopack,next build --turbo 也已 stable。Vite 用户在这一步不会有太大惊喜。Turbopack 的 HMR 体验和 Vite 相当,冷启动稍慢(3–5 秒 vs Vite 的 1–2 秒),但生产构建速度比 Webpack 快 2–5 倍。如果需要更细的性能对比与 CI 配置,可以看 Next.js Turbopack 生产构建完全指南。
测试框架的两种走法
Vite 项目里 Vitest 几乎是标配。迁到 Next.js 之后你有两个选择:
- 继续用 Vitest:官方 Vitest 集成指南 已经支持 App Router,但目前对 async Server Component 的单测还有边界情况,需要 mock 掉
server-only。适合原有测试很多、迁移预算紧的项目。
- 切到 Jest +
next/jest:Jest 对 RSC 的支持更成熟,配合 Playwright 做 e2e 是目前 Vercel 推荐的组合。
// vitest.config.ts(保留 Vitest 走法)
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
// Server Component 的 module resolution 需要单独一层
server: { deps: { inline: ['next'] } },
},
})
哪些项目不该迁:三种要留在 Vite 的信号
作为一个把迁移当收入的人,我反倒经常劝客户 "先别迁"。以下三种情况,Vite 通常仍是更合理的选择:
- 整个应用都在登录后:内部工具、admin dashboard、SaaS 的 workspace。Google 永远不会爬进去,SEO 收益等于零;SSR 的服务端算力却要你实实在在地掏钱。
- 你已经在用 TanStack Router / TanStack Start 的路线:TanStack 生态在 2026 年 Q2 进入 RC,如果你团队已经踩在这条线上,硬迁 Next.js 反而是加复杂度。
- 产品是 Electron / Tauri 桌面应用:桌面壳里不需要 SSR,Vite 的输出直接可以打进包,迁 Next.js 只会增加构建复杂度和最终包体积。
反过来,只要你的项目有公开可爬内容 + 首屏性能敏感 + 存在一个可以被 RSC 吃掉的独立后端,这三条同时满足两条,迁移的 ROI 一般在 3 个月内就能算得过账。想理解迁移后如何把 SEO 拉满,可以顺手看看 Next.js PPR 完全指南 里 "静态壳 + 动态孔洞" 的组合,是 App Router 特有的杀手锏。
常见问题
从 Vite 迁到 Next.js 值得吗?
如果你的应用需要 SEO、首屏性能对转化敏感、或者背后挂着一个独立的 REST API,那基本一定值得。迁移成本通常在 15 小时到 3 周之间,可衡量的收益包括 LCP 下降 40–55%、Lighthouse 分数提升 15–25 分、后端路由数量减半。如果是纯内部工具、无 SEO 需求,那留在 Vite 更简单也更便宜。
Vite 项目里的 React Router 怎么改成 App Router?
用文件系统对应声明式路由:<Route path="/blog/:slug"> → app/blog/[slug]/page.tsx;<Outlet /> → layout.tsx 里的 {children};useNavigate / useParams 从 next/navigation 导入;Link 的 to 改为 href。注意 Next.js 15 里 params 是 Promise,必须 await。
环境变量的 VITE_ 前缀怎么迁?
Vite 里 VITE_ 开头的公共变量对应 Next.js 的 NEXT_PUBLIC_。所有需要在浏览器读取的都要重命名前缀。数据库连接串、私钥这类只在服务端使用的变量,Next.js 里 不加任何前缀,这样它们默认就不会被打进客户端 bundle。
Vitest 还能不能继续用于 Next.js?
可以。Next.js 官方从 15.2 起提供 Vitest 集成指南,配置里加 server.deps.inline: ['next'] 就能跑通大多数场景。但异步 Server Components 的单测目前需要 mock 掉 server-only 才能工作。如果你的测试很依赖 RSC 断言,Jest + next/jest 目前的成熟度更高。
Turbopack 生产构建稳定了吗?
从 Next.js 15.3 起 next build --turbo 进入 stable channel,比 webpack 快 2–5 倍。少量复杂的 webpack loader(尤其是自定义 SVG 或 GraphQL loader)会自动 fallback 到 webpack。生产环境建议先在 preview 上跑一轮 NEXT_TELEMETRY_DEBUG=1 next build --turbo,确认没有 fallback 再切主分支。
迁移过程中可以增量上线吗?
可以,而且强烈推荐。先按阶段 1 用 output: 'export' 把整个 SPA 塞进 [[...slug]] catch-all 路由跑起来,再一路一路切成真正的 App Router 页面。每切完一路就可以合 PR、部署预览,避免几周长 PR 带来的合并噩梦。这也是 Next.js 官方指南反复强调的 "incremental migration" 心法。