从 Vite + React 迁移到 Next.js 15 App Router:带工时估算的实战 Playbook (2026)

五阶段 Playbook 教你把 Vite + React 项目迁到 Next.js 15 App Router:tsconfig 骨架、React Router 转文件路由、Server Components 数据、Turbopack 构建,附代码模板与作品集/中等 SaaS/大型项目三档真实工时估算。

Vite 迁 Next.js 15 Playbook (2026)

最后更新:2026 年 7 月 5 日

Vite + React 迁移到 Next.js 15 App Router 的核心动作只有五步:改 tsconfig.json、建 app/ 根 layout、把 React Router 换成文件系统路由、把数据请求搬到 Server Components / Server Actions、把 Vite 的构建换成 Turbopack。参照 2026 年真实案例,个人作品集或中小型站的完整迁移大约需要 15–24 工时(3–5 个工作日);中等规模的 SaaS 前端一般是 7–11 天;带复杂路由与自研 Express 后端的大型项目通常要 3–8 周。这份 Playbook 会把这五步拆成可复用的检查清单,并给出每一阶段的工时区间。

  • 五阶段迁移路径(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 / RolldownTurbopack 生产模式(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 h0.5–1 d1–2 d
2. React Router → App Router4–6 h2–4 d1–2 周
3. 数据获取 / 拆 Express2–3 h2–3 d1–3 周
4. 资源、字体、环境变量2–3 h1 d2–3 d
5. 测试 + Turbopack2–4 h1–2 d3–5 d
总计≈ 15 h / 3 d7–11 d3–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.tsxprops.params 是 Promise,需要 await
  • useSearchParams() → 仍然叫这个名字,但从 next/navigation 导入,且需要 <Suspense> 包裹
  • Linknext/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 QuerySWR 的三件套。迁到 Next.js 之后,你能选的路径其实是三条,而且这三条不是替代关系,而是共存的:

  1. Server Components 里直接 fetch / 查库:适合列表页、详情页这种 "打开就要数据" 的 SSR 场景。
  2. Server Actions:适合表单提交、乐观更新这类 mutation。用 <form action={updatePost}> 就可以,不用再写 API 路由。
  3. 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/imagenext/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/googlenext/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 之后你有两个选择:

  1. 继续用 Vitest官方 Vitest 集成指南 已经支持 App Router,但目前对 async Server Component 的单测还有边界情况,需要 mock 掉 server-only。适合原有测试很多、迁移预算紧的项目。
  2. 切到 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 通常仍是更合理的选择:

  1. 整个应用都在登录后:内部工具、admin dashboard、SaaS 的 workspace。Google 永远不会爬进去,SEO 收益等于零;SSR 的服务端算力却要你实实在在地掏钱。
  2. 你已经在用 TanStack Router / TanStack Start 的路线:TanStack 生态在 2026 年 Q2 进入 RC,如果你团队已经踩在这条线上,硬迁 Next.js 反而是加复杂度。
  3. 产品是 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 / useParamsnext/navigation 导入;Linkto 改为 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" 心法。

Jasmine Patel
关于作者 Jasmine Patel

Web framework specialist comparing Next.js to everything else so you don't have to. Migrates teams off legacy stacks for fun.