Next.js App Router 实战指南:从布局到数据获取的完整避坑手册

By | 2026年10月9日

为什么需要 App Router?

如果你之前使用 Next.js 的 Pages Router,可能已经习惯了 getServerSideProps、getStaticProps 这些数据获取方式。但 Next.js 13 引入的 App Router 是一次架构级的升级:它基于 React Server Components,允许组件在服务端渲染,同时保持客户端的交互能力。这不仅减少了客户端 JavaScript 体积,还让数据获取和布局复用变得更自然。

然而,新范式也带来了新的心智负担:'use client' 该加在哪里?缓存为什么有时不生效?布局如何嵌套?本文将通过一个博客系统的实战,带你理清这些概念,并分享我在迁移过程中总结的最佳实践。

项目初始化与结构

首先,创建一个新的 Next.js 项目(确保 Node.js 18.17 以上):


npx create-next-app@latest my-blog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

选择 --app 启用 App Router,--src-dir 将代码放在 src 目录下。初始化后的目录结构如下:


src/
  app/
    layout.tsx
    page.tsx
    globals.css
  components/
  lib/

app 目录是 App Router 的核心,每个文件夹代表一个路由段。layout.tsx 定义共享布局,page.tsx 是路由的入口页面。

步骤一:设计嵌套布局

假设我们的博客有首页、文章列表页和文章详情页。我们希望所有页面共享头部和底部,但文章详情页需要额外的侧边栏。

在 app/layout.tsx 中定义根布局:


// src/app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <body className="min-h-screen flex flex-col">
        <header className="border-b p-4">
          <nav>我的博客</nav>
        </header>
        <main className="flex-1 container mx-auto p-4">{children}</main>
        <footer className="border-t p-4 text-center">© 2025</footer>
      </body>
    </html>
  );
}

接着,为文章详情页创建嵌套布局。在 app/posts/[slug]/layout.tsx 中:


// src/app/posts/[slug]/layout.tsx
export default function PostLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex gap-8">
      <article className="flex-1">{children}</article>
      <aside className="w-64 hidden lg:block">
        <h3>相关文章</h3>
        {/* 侧边栏内容 */}
      </aside>
    </div>
  );
}

这样,只有文章详情页会显示侧边栏,其他页面不受影响。布局可以无限嵌套,且默认是服务端组件。

💡 注意:根布局必须包含 和 标签,且不能是客户端组件(不能加 'use client')。

步骤二:服务端组件与客户端组件

App Router 默认所有组件都是服务端组件(Server Components)。它们可以直接访问后端资源,且不会打包到客户端。但如果你需要状态、事件处理或浏览器 API,就必须使用客户端组件。

规则:在文件顶部添加 'use client' 指令。

例如,一个点赞按钮需要交互:


// src/components/LikeButton.tsx
'use client';

import { useState } from 'react';

export default function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  return (
    <button
      onClick={() => setLikes(likes + 1)}
      className="px-4 py-2 bg-blue-500 text-white rounded"
    >
      👍 {likes}
    </button>
  );
}

然后在服务端组件中引入它:


// src/app/posts/[slug]/page.tsx
import LikeButton from '@/components/LikeButton';

export default function PostPage() {
  // 假设从数据库获取文章
  const post = { title: 'Hello', content: '...', likes: 42 };
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton initialLikes={post.likes} />
    </div>
  );
}

最佳实践:尽可能将客户端组件推到叶子节点,以减小客户端 bundle。例如,不要将整个页面标记为客户端组件,而是只把交互部分抽离。

💡 易错点:服务端组件不能使用 useState、useEffect 等 Hook,也不能传递函数给客户端组件(除非是 Server Actions)。

步骤三:数据获取与缓存

App Router 推荐在服务端组件中直接使用 async/await 获取数据。Next.js 扩展了 fetch API,提供了自动缓存和去重。

静态数据获取

默认情况下,fetch 在构建时执行并缓存结果。例如获取文章列表:


// src/app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    // 默认缓存,等同于 force-cache
  });
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((post: any) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

动态数据获取

如果需要实时数据,可以设置 cache: 'no-store':


const res = await fetch('https://api.example.com/posts', {
  cache: 'no-store',
});

或者使用增量静态再生(ISR):


const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 }, // 每60秒重新验证
});

使用 Server Actions 修改数据

App Router 引入了 Server Actions,允许你在服务端直接处理表单提交,无需创建 API 路由。


// src/app/posts/new/page.tsx
import { redirect } from 'next/navigation';

async function createPost(formData: FormData) {
  'use server';
  const title = formData.get('title') as string;
  // 模拟保存到数据库
  console.log('Creating post:', title);
  redirect('/posts');
}

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required className="border p-2" />
      <button type="submit" className="bg-blue-500 text-white p-2">
        创建
      </button>
    </form>
  );
}

💡 注意:Server Actions 必须定义在服务端组件或单独的文件中,并加上 'use server' 指令。

步骤四:处理动态路由与元数据

对于文章详情页,我们需要根据 slug 获取数据,并动态生成页面标题。


// src/app/posts/[slug]/page.tsx
import { Metadata } from 'next';

async function getPost(slug: string) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`);
  return res.json();
}

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);
  return { title: post.title, description: post.body.slice(0, 100) };
}

export default async function PostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  return (
    <div>
      <h1 className="text-2xl font-bold">{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
}

generateMetadata 会在服务端运行,并自动注入到 中。

常见坑与解决方案

1. 缓存导致数据不更新

在开发模式下,fetch 默认不缓存,但生产环境会缓存。如果你发现数据不更新,检查是否使用了默认缓存。解决方案:使用 revalidate 或 no-store。

2. 客户端组件中无法使用 async/await

客户端组件不能是 async 函数。如果你需要在客户端获取数据,使用 useEffect 或 SWR。

3. 布局与页面之间的数据传递

布局不能直接向页面传递 props。如果需要共享数据,可以使用 React Context(在客户端组件中)或通过 fetch 在布局和页面中分别获取(Next.js 会自动去重)。

4. 动态路由参数类型

在 TypeScript 中,params 和 searchParams 是 Promise 吗?在 Next.js 15 中,它们变成了 Promise,需要 await。例如:


export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  // ...
}

请根据你的 Next.js 版本调整。

总结

App Router 通过服务端组件和嵌套布局,让全栈开发更直观。关键点:

  • 默认使用服务端组件,仅在需要交互时添加 'use client'。
  • 利用 fetch 的缓存和 revalidate 控制数据新鲜度。
  • 使用 Server Actions 处理表单,减少 API 路由。
  • 动态路由配合 generateMetadata 优化 SEO。

下一步,你可以探索并行路由、拦截路由等高级特性,或结合 Prisma 构建全栈应用。

延伸阅读:

  • Next.js 官方文档:App Router
  • React Server Components 规范
  • Vercel 模板:Next.js 博客示例