0. 写在前面:给 React 开发者的定位
如果你已经会 React,Next.js 本质上是给 React 补齐了三块东西:
| React 缺什么 | Next.js 提供 |
|---|---|
| 路由 | 基于文件系统的路由(App Router) |
| 服务端渲染 / 数据获取 | Server Components、Server Actions、流式渲染 |
| 工程化(打包、优化、部署) | 内置编译、图片/字体优化、部署适配 |
Next 15 最大的心智转变是:默认所有组件都是 Server Component(服务端组件),只有你显式声明 'use client' 的才是 Client Component。这和你过去写 React 的直觉完全相反,是整篇教程的主线。
1. 环境与项目初始化
npx create-next-app@latest my-app
交互式选项推荐:
✔ TypeScript → Yes
✔ ESLint → Yes
✔ Tailwind CSS → Yes(可选)
✔ src/ directory → Yes(代码与配置分离更清晰)
✔ App Router → Yes(本教程主角)
✔ Turbopack → Yes(Next 15 默认的快速打包器)
✔ import alias → @/*
启动:
npm run dev # 默认 http://localhost:3000,使用 Turbopack
目录结构(启用 src):
my-app/
├─ src/
│ └─ app/ # App Router 的根,所有路由在这里
│ ├─ layout.tsx # 根布局(必须有)
│ ├─ page.tsx # 首页 /
│ └─ globals.css
├─ public/ # 静态资源,直接通过 / 访问
├─ next.config.ts
└─ tsconfig.json
2. App Router 路由系统(核心)
App Router 用文件夹定义路由路径,用特殊文件名定义这个路由层级的行为。
2.1 特殊文件约定
| 文件名 | 作用 |
|---|---|
page.tsx | 该路由的页面 UI(有它才可被访问) |
layout.tsx | 包裹子路由的共享布局(切换路由时不重新渲染) |
loading.tsx | 加载态 UI(基于 Suspense 自动接入) |
error.tsx | 错误边界(必须是 Client Component) |
not-found.tsx | 404 UI |
route.ts | API 路由(Route Handler,替代 Express) |
template.tsx | 类似 layout,但每次导航都重新挂载 |
2.2 路由映射示例
app/
├─ page.tsx → /
├─ about/page.tsx → /about
├─ blog/
│ ├─ page.tsx → /blog
│ └─ [slug]/page.tsx → /blog/:slug (动态路由)
├─ shop/
│ └─ [...categories]/page.tsx → /shop/a/b/c (捕获所有段)
└─ (marketing)/ → 路由组,括号不出现在 URL 中
└─ pricing/page.tsx → /pricing
2.3 动态路由参数
Next 15 中 params 和 searchParams 变成了 Promise(这是 15 的破坏性变更),必须 await:
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>文章:{slug}</h1>
}
2.4 嵌套布局
布局会自动嵌套。app/layout.tsx 包住所有页面,app/blog/layout.tsx 只包住 /blog/*:
// app/layout.tsx —— 根布局必须包含 <html> 和 <body>
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body>
<nav>全站导航</nav>
{children}
</body>
</html>
)
}
2.5 链接与导航
import Link from 'next/link'
// 客户端导航(预取、无刷新)
<Link href="/blog/hello">去文章</Link>
编程式导航(只能在 Client Component):
'use client'
import { useRouter } from 'next/navigation' // 注意是 next/navigation,不是 next/router
const router = useRouter()
router.push('/dashboard')
3. Server Components vs Client Components(最重要的一章)
3.1 默认是 Server Component
// 没有 'use client',这就是 Server Component
// 在服务端运行,可以直接读数据库、读文件、用密钥
import { db } from '@/lib/db'
export default async function Page() {
const users = await db.user.findMany() // 直接 await,无需 useEffect
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
Server Component 的特点:
- 可以是 async 函数,直接
await取数据 - 代码不进入客户端 bundle,体积更小、密钥安全
- 不能用
useState/useEffect/ 事件处理 / 浏览器 API
3.2 何时需要 Client Component
需要交互、状态、生命周期、浏览器 API 时,文件顶部加 'use client':
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
3.3 组合规则(关键陷阱)
- Server Component 可以导入并渲染 Client Component ✅
- Client Component 不能导入 Server Component ❌,但可以通过
children接收它:
// ✅ 正确:把 Server Component 作为 children 传给 Client Component
// app/page.tsx (Server)
import ClientWrapper from './client-wrapper'
import ServerInfo from './server-info' // Server Component
export default function Page() {
return (
<ClientWrapper>
<ServerInfo /> {/* 以 children 形式注入,不算 import */}
</ClientWrapper>
)
}
3.4 心智模型
默认把组件留在服务端。只在"叶子节点"——真正需要交互的小组件——才标
'use client',把客户端边界尽量下推,bundle 才小。
4. 数据获取
4.1 服务端直接取数据(首选)
export default async function Page() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return <PostList posts={posts} />
}
4.2 缓存与重新验证
Next 15 改了默认值:fetch 默认不再缓存(no-store),需要缓存要显式声明。
// 强缓存(构建时/首次后复用)
fetch(url, { cache: 'force-cache' })
// 定时重新验证(ISR,每 60 秒最多重新取一次)
fetch(url, { next: { revalidate: 60 } })
// 显式不缓存(默认行为)
fetch(url, { cache: 'no-store' })
也可在文件级别声明:
export const revalidate = 3600 // 整个路由每小时再验证
export const dynamic = 'force-dynamic' // 强制每次动态渲染
4.3 并行取数据,避免瀑布
export default async function Page() {
// 并行发起,而不是一个 await 完再发下一个
const [user, posts] = await Promise.all([
getUser(),
getPosts(),
])
return <Profile user={user} posts={posts} />
}
5. 流式渲染与加载态
放一个 loading.tsx,Next 自动用 Suspense 包裹该路由,数据没好时先显示加载 UI:
// app/blog/loading.tsx
export default function Loading() {
return <div>加载中…</div>
}
更细粒度:用 <Suspense> 让页面其余部分先渲染,慢的部分单独流式输出:
import { Suspense } from 'react'
export default function Page() {
return (
<>
<Header /> {/* 立即显示 */}
<Suspense fallback={<Spinner />}>
<SlowComments /> {/* 数据好了再补上,不阻塞整页 */}
</Suspense>
</>
)
}
6. Server Actions(表单与变更)
Server Actions 让你在不手写 API 的情况下,从客户端调用服务端函数。函数顶部加 'use server':
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createTodo(formData: FormData) {
const title = formData.get('title') as string
await db.todo.create({ data: { title } })
revalidatePath('/todos') // 让 /todos 重新取数据刷新
}
直接绑到表单 action(无需 onSubmit、无需 fetch):
import { createTodo } from './actions'
export default function Page() {
return (
<form action={createTodo}>
<input name="title" />
<button type="submit">添加</button>
</form>
)
}
客户端处理 pending 状态:
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? '提交中…' : '提交'}</button>
}
7. Route Handlers(API 路由)
用 route.ts 写 HTTP 接口,基于 Web 标准的 Request/Response:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const query = req.nextUrl.searchParams.get('q')
const posts = await getPosts(query)
return NextResponse.json(posts)
}
export async function POST(req: NextRequest) {
const body = await req.json()
const created = await createPost(body)
return NextResponse.json(created, { status: 201 })
}
8. 内置优化
8.1 图片
import Image from 'next/image'
<Image src="/hero.jpg" width={800} height={400} alt="封面" priority />
// 自动:懒加载、响应式 srcset、防止布局抖动(CLS)、转 WebP/AVIF
8.2 字体(零布局抖动、自托管)
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function RootLayout({ children }) {
return <html className={inter.className}><body>{children}</body></html>
}
8.3 元数据(SEO)
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '我的网站',
description: '一个用 Next.js 构建的站点',
}
// 动态元数据
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return { title: post.title }
}
9. 渲染策略对照
| 策略 | 含义 | 怎么触发 |
|---|---|---|
| 静态(SSG) | 构建时生成 HTML | 默认(无动态数据时) |
| ISR | 静态 + 定时再生成 | revalidate |
| 动态(SSR) | 每次请求渲染 | cache: 'no-store' / 用了 cookies()、headers() |
| 客户端(CSR) | 浏览器渲染 | 'use client' 组件内取数据 |
对动态路由可预生成参数:
// 构建时生成这些 slug 的静态页
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(p => ({ slug: p.slug }))
}
10. 中间件
// middleware.ts (放项目根,不在 app 内)
import { NextResponse, NextRequest } from 'next/server'
export function middleware(req: NextRequest) {
const token = req.cookies.get('token')
if (!token && req.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', req.url))
}
}
export const config = { matcher: ['/dashboard/:path*'] } // 只对这些路径生效
11. 构建与部署
npm run build # 产出 .next/,会标注每个路由是静态还是动态
npm run start # 生产模式启动
- Vercel:
git push即自动部署,零配置(Next 是 Vercel 出品,适配最好)。 - 自托管:
output: 'standalone'可打包成最小 Node 服务,配合 Docker。 - 纯静态导出:
output: 'export',但会失去 SSR / Server Actions 等能力。
12. 学习路线小结
- 先吃透 Server / Client Component 边界——这是 80% 的认知成本。
- 掌握 App Router 文件约定 + params 是 Promise(15 新特性)。
- 数据获取记住 15 默认不缓存,按需
revalidate。 - 变更操作优先 Server Actions,而不是手写 API + fetch。
- 用 Suspense / loading.tsx 做流式与加载态。
生产级补充(纯前端场景:后端是独立服务)
适用场景:后端由独立服务(Java / Go / Node 等)提供,Next 只负责页面渲染和调用现成 API。 因此本节不涉及数据库、服务端认证实现,聚焦前端工程化、API 对接、安全、测试、部署。
13. 对接独立后端 API
13.1 用 BFF 层代理,而不是浏览器直连后端
即使后端独立,也建议让 Next 的 Server Component / Route Handler 充当**前端的后端(BFF)**去调真正的后端,而不是浏览器直接打后端域名。好处:隐藏后端地址、统一加 token、规避跨域、聚合多个接口。
// app/products/page.tsx —— Server Component 在服务端调后端,密钥不进浏览器
export default async function Page() {
const res = await fetch(`${process.env.API_BASE}/products`, {
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('后端返回错误')
const products = await res.json()
return <ProductList products={products} />
}
13.2 客户端取数据用 SWR / TanStack Query
需要轮询、自动重试、缓存、乐观更新时,客户端组件配 SWR 或 TanStack Query,而不是裸 useEffect + fetch:
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(r => r.json())
export default function Cart() {
const { data, error, isLoading } = useSWR('/api/cart', fetcher, {
revalidateOnFocus: true,
})
if (isLoading) return <Spinner />
if (error) return <ErrorBox />
return <CartView items={data} />
}
13.3 用 Route Handler 做代理,统一鉴权与错误
// app/api/proxy/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params
const token = req.cookies.get('token')?.value
const res = await fetch(`${process.env.API_BASE}/${path.join('/')}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
return NextResponse.json(await res.json(), { status: res.status })
}
14. 环境变量与配置
.env.local # 本地开发,不提交
.env.production # 生产
process.env.API_BASE // 仅服务端可读(安全)
process.env.NEXT_PUBLIC_GA_ID // 加 NEXT_PUBLIC_ 前缀才会注入浏览器
铁律:任何密钥(API token、私钥)绝不能加 NEXT_PUBLIC_ 前缀,否则会被打进客户端 bundle 泄漏。建议用 zod 在启动时校验环境变量:
// env.ts
import { z } from 'zod'
const schema = z.object({
API_BASE: z.string().url(),
API_TOKEN: z.string().min(1),
})
export const env = schema.parse(process.env) // 缺失/格式错误则构建即失败
15. 错误处理与监控
15.1 路由级错误边界
// app/error.tsx —— 必须是 Client Component
'use client'
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<h2>出错了</h2>
<button onClick={reset}>重试</button>
</div>
)
}
// app/global-error.tsx —— 兜底根布局崩溃
15.2 接入 Sentry(前端错误 + 性能)
npx @sentry/wizard@latest -i nextjs
向导会自动配置 client / server / edge 三套初始化和 source map 上传。生产环境务必上报客户端 JS 报错,否则用户报错你毫无感知。
16. 安全(纯前端同样要做)
| 项 | 做法 |
|---|---|
| 安全响应头 | 在 next.config.ts 的 headers() 配 Content-Security-Policy、X-Frame-Options、Strict-Transport-Security 等 |
| XSS | 避免 dangerouslySetInnerHTML;必须用时先 DOMPurify 净化 |
| token 存储 | 优先 httpOnly cookie(由 BFF 设置),避免存 localStorage 被脚本读取 |
| CSRF | 走 cookie 鉴权时,后端需校验 CSRF token 或用 SameSite cookie |
| 依赖漏洞 | CI 跑 npm audit / Dependabot |
// next.config.ts
const csp = "default-src 'self'; img-src 'self' data: https:; script-src 'self'"
export default {
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'Content-Security-Policy', value: csp },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
}]
},
}
17. 测试
# 单元 / 组件
npm i -D vitest @testing-library/react @testing-library/jest-dom
# E2E
npm i -D @playwright/test
组件测试:
// counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './counter'
test('点击递增', () => {
render(<Counter />)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('1')).toBeInTheDocument()
})
E2E(测真实页面流程,生产项目硬要求):
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
test('首页能下单', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: '加入购物车' }).click()
await expect(page.getByText('已加入')).toBeVisible()
})
18. 性能与可观测性
-
Bundle 分析:
@next/bundle-analyzer,揪出过大的依赖。 -
动态导入:大组件 / 第三方库按需加载,缩小首屏 bundle。
import dynamic from 'next/dynamic' const Chart = dynamic(() => import('./Chart'), { ssr: false, loading: () => <Spinner /> }) -
Core Web Vitals:用
useReportWebVitals上报 LCP / CLS / INP 到分析平台。 -
图片/字体:坚持用
next/image、next/font,这是 CLS 与加载性能的关键。
19. 国际化(i18n)
App Router 没有内置 i18n,生产常用 next-intl:
app/[locale]/layout.tsx
app/[locale]/page.tsx
messages/zh.json
messages/en.json
配中间件做 locale 检测与重定向,组件里用 useTranslations('Home') 取文案。
20. CI/CD
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npx playwright install --with-deps && npm run test:e2e
- run: npm run build
部署:Vercel 连 Git 自动 CI/CD;自托管则在流水线产物上 output: 'standalone' 打 Docker 镜像。
21. 纯前端生产清单(上线前自查)
- 密钥无
NEXT_PUBLIC_前缀,环境变量启动时校验 - 所有外部 API 调用有错误处理与超时
-
error.tsx/global-error.tsx已配,Sentry 已接 - 安全响应头(CSP 等)已配
- 单测 + 关键流程 E2E 覆盖
- bundle 体积 review,大依赖动态导入
- 图片用
next/image、字体用next/font - CI 跑 lint / test / build,绿了才合并