Next.js App Router 全栈开发指南:服务器组件与路由

Next.js 是目前使用最广的 React 全栈框架。13 版引入的 App Router 把"目录即路由、服务器组件优先"确立为默认心智,到了 15、16 版,它已经是整个框架路由与数据层的基石,不再只是 Pages Router 的替代品。这篇指南从最小项目结构讲起,落到能直接运行的代码,最后给出从 Pages Router 迁移时的取舍,适合已经会写 React 组件、想全面切到 App Router 的开发者。

Next.js 属于前端搭建建站技术两个分类的核心内容。想先补 React 新特性,可以读React 19 实战指南;首屏与 SSR 性能的深挖见Next.js SSR 性能优化

从一个最小项目看目录约定

执行下面的命令会得到一个带 TypeScript、Tailwind、ESLint 的 App Router 项目,Turbopack 已经是默认打包器:

npx create-next-app@latest my-app --ts --tailwind --eslint --app
cd my-app && npm run dev

核心结构只有几个文件:

my-app/
├── app/
│   ├── layout.tsx     # 根布局:必须包含 <html> 与 <body>
│   ├── page.tsx       # 首页,对应 /
│   └── globals.css
├── public/
└── next.config.ts

App Router 的约定很直观:目录嵌套决定 URL 嵌套,每个路由目录里放特殊文件来声明它的行为。

文件 职责
layout.tsx 共享布局,子页面切换时保留状态
page.tsx 页面内容,对应可访问的路由
loading.tsx 加载态,配合 Suspense 做流式渲染
error.tsx 错误边界,能按段捕获异常
not-found.tsx 404 页面
route.ts 定义 API 路由(GET/POST 等)

动态路由用方括号目录表达:app/blog/[slug]/page.tsx 匹配 /blog/hello-world,参数通过 params 拿到;想匹配任意多段可以用 [...slug]。布局支持无限嵌套,所以"站点导航 / 博客栏目 / 单篇文章"这类层级可以自然地映射成一棵组件树,每一层都有自己的 loading 与 error 边界。

服务器组件与客户端组件

app/ 下的组件默认都是服务器组件(Server Component)。写在里面的代码只在服务器执行,不会打包进浏览器:可以直接 await 数据库查询、读取文件、调用内部服务。只有需要交互、状态或浏览器 API 时,才在文件顶部加 "use client"

一个典型的博客首页,在服务器组件里直连数据库渲染:

// app/blog/page.tsx —— 默认是服务器组件
export default async function BlogIndex() {
  const posts = await db.post.findMany({ take: 20 });
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          <a href={`/blog/${p.slug}`}>{p.title}</a>
        </li>
      ))}
    </ul>
  );
}

需要点击的"点赞"按钮则放进客户端组件:

// app/blog/like-button.tsx
"use client";
export function LikeButton({ postId }: { postId: string }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>赞 {count}</button>;
}

两个常见误区值得记下来:服务器组件里不能用 useStateonClickuseEffect,报错先检查是不是漏了 "use client";反过来,客户端组件里别放数据库连接和密钥。把"需要交互的叶子节点"标成客户端、其余保持服务器组件,是这套模型的核心纪律,也是它能显著减小打包体积的原因。

数据获取、缓存与流式渲染

服务器组件里直接 await 数据即可,配合 next/cache 控制缓存。Next.js 15 起 fetch 默认不再缓存,需要显式声明:

export default async function Page() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 }, // 每小时后台重建一次
  });
  const posts = await res.json();
  // ...
}

revalidate 相当于把 ISR 带进了组件级:页面按需重建,不必整站重新部署。更细粒度的做法是标签缓存——给"文章列表"打一个 tag,发布新文章后调用 revalidateTag('posts'),只让相关页面失效,其余缓存照常命中。

流式渲染(Streaming)解决的是"整页等一个慢接口"的问题。把页面拆成多个 Suspense 边界,每个边界先吐骨架屏、数据到位后再填内容,首字节(TTFB)明显变快,弱网下的感知速度也更好。loading.tsx 本质就是给整条路由提供一个默认的 Suspense 边界,所以它通常和异步数据获取配合使用。

从 Pages Router 迁移

迁移最怕推翻重来。App Router 可以和 Pages Router 共存,按路由渐进切换,不必一次搬完。对应关系大致如下:

Pages Router App Router
pages/index.tsx app/page.tsx
pages/blog/[id].tsx app/blog/[id]/page.tsx
getServerSideProps 服务器组件内直接取数
getStaticProps + revalidate fetch + next.revalidate
_app.tsx layout.tsx
pages/api/* app/api/*/route.ts

迁移时最常踩的三个坑:一是 _app.tsx 里用组件包裹注入的全局布局,在 App Router 要改成嵌套 layout.tsx,否则子页面切换会丢状态;二是大量 useEffect 拉数据的组件,先想想能不能直接搬进服务器组件同步 await,通常能删掉一半样板代码;三是 next/router 的 API 要换成 next/navigationuseRouter,两者并不完全一致。

一个真实的落地案例

假设要重做一个"企业官网 + 内容博客 + 后台面板"。一个典型的 App Router 切法:

  • app/(site)/**:官网与博客。全部用服务器组件渲染,公开内容配合 revalidate 静态化,SEO 和首屏都有保障;
  • app/(dashboard)/**:后台。登录后访问,在 layout.tsx 里统一做鉴权,表单与图表等交互组件集中在叶子节点;
  • 数据层:公开内容走服务器组件直连或缓存接口,写操作通过 route.ts 暴露的 API 处理。

这种"路由分组 + 服务器组件优先"的结构比旧的"纯客户端渲染 + 全局状态"更容易维护,还能和PWA 实现指南搭配,把移动端体验再往前推一步。

常见问题

  • useState 一用就报错? 组件默认是服务器组件,要交互就得加 "use client"
  • loading.tsx 不生效? 只有路由里有真正挂起的异步操作时才会显示,纯静态页面不会触发。
  • 数据一直不更新? Next.js 15+ 默认不缓存 fetch,需要显式 revalidate 或标签缓存。
  • 和 Astro 怎么选? 需要大量动态交互的 SaaS 和后台,用 Next.js 更顺手;纯内容站追求极致静态可以看Astro 建站指南

常规页面结构也可以参考本站的HTML 页面模板响应式导航模板,把布局和样式先搭起来。

参考:Next.js 官方文档 https://nextjs.org/docs/app/getting-started/installation;App Router 数据获取与缓存 https://nextjs.org/docs/app/building-your-application/data-fetching