Next.js 15 详细开发教程(App Router)

0 阅读10分钟

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.tsx404 UI
route.tsAPI 路由(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 中 paramssearchParams 变成了 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. 学习路线小结

  1. 先吃透 Server / Client Component 边界——这是 80% 的认知成本。
  2. 掌握 App Router 文件约定 + params 是 Promise(15 新特性)。
  3. 数据获取记住 15 默认不缓存,按需 revalidate
  4. 变更操作优先 Server Actions,而不是手写 API + fetch。
  5. 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.tsheaders()Content-Security-PolicyX-Frame-OptionsStrict-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/imagenext/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,绿了才合并