mcpField
N
nextjs-app-router-fundamentals

nextjs-app-router-fundamentals

github.com/wsimmonds开发1092.2k 次安装53 周前更新
代码仓库

关于 nextjs-app-router-fundamentals

nextjs-app-router-fundamentals 面向使用 App Router 构建或迁移 Next.js 13+ 应用的开发者。它讲解路由约定、布局、元数据、数据获取等模式,并提供避免使用 `any` 的 TypeScript 指引。

主要功能

  1. 01App Router 文件与路由约定
  2. 02Pages Router 迁移指南
  3. 03布局与嵌套路由
  4. 04静态和动态元数据
  5. 05服务端与客户端组件模式
  6. 06数据获取与静态路由生成

适用场景

  1. 01启动 Next.js 13+ App Router 项目
  2. 02从 Pages Router 迁移路由
  3. 03实现嵌套和动态路由
  4. 04构建元数据与静态页面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wsimmonds/claude-nextjs-skills --skill nextjs-app-router-fundamentals

安装后在 Claude Code 中输入/nextjs-app-router-fundamentals确认已加载。

详细介绍

Next.js App Router 基础

概述

全面介绍 Next.js App Router(Next.js 13+),涵盖从 Pages Router 迁移、基于文件的路由约定、布局、元数据处理以及现代 Next.js 模式。

TypeScript:绝不使用 any 类型

重要规则: 此代码库启用了 @typescript-eslint/no-explicit-any。使用 any 会导致构建失败。

❌ 错误:

function handleSubmit(e: any) { ... }
const data: any[] = [];

✅ 正确:

function handleSubmit(e: React.FormEvent<HTMLFormElement>) { ... }
const data: string[] = [];
常见的 Next.js 类型模式
// Page props
function Page({ params }: { params: { slug: string } }) { ... }
function Page({ searchParams }: { searchParams: { [key: string]: string | string[] | undefined } }) { ... }

// Form events
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { ... }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... }

// Server actions
async function myAction(formData: FormData) { ... }

何时使用此技能

在以下情况下使用此技能:

  • 从 Pages Router(pages/ 目录)迁移到 App Router(app/ 目录)
  • 从头开始创建 Next.js 13+ 应用
  • 使用布局、模板和嵌套路由
  • 实现元数据和 SEO 优化
  • 使用 App Router 路由约定构建应用
  • 处理路由组、并行路由或拦截路由基础知识

核心概念

App Router 与 Pages Router

Pages Router(旧版 - Next.js 12 及更早版本):

pages/
├── index.tsx              # Route: /
├── about.tsx              # Route: /about
├── _app.tsx               # Custom App component
├── _document.tsx          # Custom Document component
└── api/                   # API routes
    └── hello.ts           # API endpoint: /api/hello

App Router(现代 - Next.js 13+):

app/
├── layout.tsx             # Root layout (required)
├── page.tsx               # Route: /
├── about/                 # Route: /about
│   └── page.tsx
├── blog/
│   ├── layout.tsx         # Nested layout
│   └── [slug]/
│       └── page.tsx       # Dynamic route: /blog/:slug
└── api/                   # Route handlers
    └── hello/
        └── route.ts       # API endpoint: /api/hello
文件约定

App Router 中的特殊文件:

  • layout.tsx - 某个片段及其子级共用的 UI(保留状态,不会重新渲染)
  • page.tsx - 路由的专属 UI,使该路由可公开访问
  • loading.tsx - 使用 React Suspense 的加载 UI
  • error.tsx - 使用错误边界的错误 UI
  • not-found.tsx - 404 UI
  • template.tsx - 类似于布局,但在导航时会重新渲染
  • route.ts - API 端点(路由处理程序)

共置:

  • 可以在 app/ 中共置组件、测试和其他文件
  • 只有 page.tsx 和 route.ts 文件会创建公开路由
  • 其他文件(组件、工具、测试)不能作为路由访问

迁移指南:从 Pages Router 迁移到 App Router

步骤 1:了解当前结构

检查现有的 Pages Router 设置:

  • 查看 pages/ 目录结构
  • 找出 _app.tsx——用于处理全局状态、布局和提供程序
  • 找出 _document.tsx——用于自定义 HTML 结构
  • 记录元数据的用法(next/head、<Head> 组件)
  • 列出所有路由和动态片段
步骤 2:创建根布局

创建 app/layout.tsx——所有 App Router 应用都必须包含此文件:

// app/layout.tsx
export const metadata = {
  title: 'My App',
  description: 'App description',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

迁移说明:

  • 将 _document.tsx 中的 HTML 结构移至 layout.tsx
  • 将 _app.tsx 中的全局提供程序和包装器移至 layout.tsx
  • 将 <Head> 元数据转换为 metadata 导出
  • 根布局必须包含 <html> 和 <body> 标签
步骤 3:将页面迁移为路由

简单页面迁移:

// Before: pages/index.tsx
import Head from 'next/head';

export default function Home() {
  return (
    <>
      <Head>
        <title>Home Page</title>
      </Head>
      <main>
        <h1>Welcome</h1>
      </main>
    </>
  );
}
// After: app/page.tsx
export default function Home() {
  return (
    <main>
      <h1>Welcome</h1>
    </main>
  );
}

// Metadata moved to layout.tsx or exported here
export const metadata = {
  title: 'Home Page',
};

嵌套路由迁移:

// Before: pages/blog/[slug].tsx
export default function BlogPost() { ... }
// After: app/blog/[slug]/page.tsx
export default function BlogPost() { ... }
步骤 4:更新导航

将锚点标签替换为 Next.js Link:

// Before (incorrect in App Router)
<a href="/about">About</a>

// After (correct)
import Link from 'next/link';
<Link href="/about">About</Link>
步骤 5:清理 Pages 目录

迁移完成后:

  • 从 pages/ 目录中删除所有页面文件
  • 如果尚未迁移 API 路由,则保留 pages/api/
  • 删除 _app.tsx 和 _document.tsx(其功能已移至布局)
  • 可以选择删除空的 pages/ 目录

元数据处理

静态元数据
// app/page.tsx or app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'My Page',
  description: 'Page description',
  keywords: ['nextjs', 'react'],
  openGraph: {
    title: 'My Page',
    description: 'Page description',
    images: ['/og-image.jpg'],
  },
};
动态元数据
// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
  };
}

布局与嵌套

创建嵌套布局
// app/layout.tsx - Root layout
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}

// app/blog/layout.tsx - Blog layout
export default function BlogLayout({ children }) {
  return (
    <div>
      <BlogSidebar />
      <main>{children}</main>
    </div>
  );
}

布局行为:

  • 布局会在导航过程中保留状态
  • 路由更改时布局不会重新渲染
  • 父布局会包裹子布局
  • 根布局为必需项,会包裹整个应用

路由模式

动态路由
// app/blog/[slug]/page.tsx
export default function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  return <article>Post: {params.slug}</article>;
}
全匹配路由
// app/shop/[...slug]/page.tsx - Matches /shop/a, /shop/a/b, etc.
export default function Shop({
  params
}: {
  params: { slug: string[] }
}) {
  return <div>Path: {params.slug.join('/')}</div>;
}
可选全匹配路由
// app/shop/[[...slug]]/page.tsx - Matches /shop AND /shop/a, /shop/a/b
路由组

对路由进行分组,但不影响 URL:

app/
├── (marketing)/
│   ├── about/
│   │   └── page.tsx      # /about
│   └── contact/
│       └── page.tsx      # /contact
└── (shop)/
    └── products/
        └── page.tsx      # /products

常见迁移问题

问题 1:忘记根布局的 HTML 标签

错误:

export default function RootLayout({ children }) {
  return <div>{children}</div>; // Missing <html> and <body>
}

正确:

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
问题 2:在 App Router 中使用 next/head

错误:

import Head from 'next/head';

export default function Page() {
  return (
    <>
      <Head><title>Title</title></Head>
      <main>Content</main>
    </>
  );
}

正确:

export const metadata = { title: 'Title' };

export default function Page() {
  return <main>Content</main>;
}
问题 3:未删除 Pages 目录

迁移路由后,删除旧的 pages/ 目录文件,以免造成混淆。如果存在冲突的路由,构建会失败。

陷阱 4:缺少 page.tsx 文件

没有 page.tsx 文件就无法访问路由。仅有布局不会创建路由。

app/
├── blog/
│   ├── layout.tsx   # NOT a route
│   └── page.tsx     # This makes /blog accessible
陷阱 5:链接用法错误

错误:

<a href="/about">About</a>  // Works but causes full page reload

正确:

import Link from 'next/link';
<Link href="/about">About</Link>  // Client-side navigation

服务端组件与客户端组件

默认:服务端组件

app/ 中的所有组件默认都是服务端组件:

// app/page.tsx - Server Component (default)
export default async function Page() {
  const data = await fetch('https://api.example.com/data');
  const json = await data.json();

  return <div>{json.title}</div>;
}

优点:

  • 可直接使用 async/await
  • 可直接访问数据库/API
  • 客户端 JavaScript 为零
  • 自动代码拆分
客户端组件

需要以下功能时,使用 'use client' 指令:

  • 交互元素(onClick、onChange 等)
  • React Hooks(useState、useEffect、useContext 等)
  • 浏览器 API(window、localStorage 等)
  • 事件监听器
// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

数据获取模式

服务端组件数据获取
// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // Revalidate every hour
  });
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
并行数据获取
export default async function Page() {
  // Fetch in parallel
  const [posts, users] = await Promise.all([
    fetch('https://api.example.com/posts').then(r => r.json()),
    fetch('https://api.example.com/users').then(r => r.json()),
  ]);

  return (/* render */);
}

使用 generateStaticParams 生成静态站点

概述

generateStaticParams 是 App Router 中与 Pages Router 的 getStaticPaths 等效的功能。它会在构建时为动态路由生成静态页面。

基本用法
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
  // Return array of params to pre-render
  return [
    { id: '1' },
    { id: '2' },
    { id: '3' },
  ];
}

export default function BlogPost({
  params
}: {
  params: { id: string }
}) {
  return <article>Blog post {params.id}</article>;
}

要点:

  • 返回一个对象数组,其中包含路由参数键
  • 每个对象代表一个要在构建时预渲染的页面
  • 函数必须导出,且名称为 generateStaticParams
  • 仅适用于服务端组件(不能使用 'use client' 指令)
  • 替代 Pages Router 的 getStaticPaths
获取静态参数的数据
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());

  return posts.map((post: { slug: string }) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}
多个动态片段
// app/products/[category]/[id]/page.tsx
export async function generateStaticParams() {
  const categories = await getCategories();

  const params = [];
  for (const category of categories) {
    const products = await getProducts(category.slug);
    for (const product of products) {
      params.push({
        category: category.slug,
        id: product.id,
      });
    }
  }

  return params;
}

export default function ProductPage({
  params
}: {
  params: { category: string; id: string }
}) {
  return <div>Category: {params.category}, Product: {params.id}</div>;
}
动态行为配置
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }];
}

// Control behavior for non-pre-rendered paths
export const dynamicParams = true; // default - allows runtime generation
// export const dynamicParams = false; // returns 404 for non-pre-rendered paths

export default function BlogPost({
  params
}: {
  params: { id: string }
}) {
  return <article>Post {params.id}</article>;
}

选项:

  • dynamicParams = true(默认):未预渲染的路径会按需生成
  • dynamicParams = false:未预渲染的路径会返回 404
常见模式

模式 1:基于 ID 的简单路由

export async function generateStaticParams() {
  return [
    { id: '1' },
    { id: '2' },
    { id: '3' },
  ];
}

模式 2:从 API 获取

export async function generateStaticParams() {
  const items = await fetch('https://api.example.com/items').then(r => r.json());
  return items.map(item => ({ id: item.id }));
}

模式 3:数据库查询

export async function generateStaticParams() {
  const posts = await db.post.findMany();
  return posts.map(post => ({ slug: post.slug }));
}
从 Pages Router 迁移

之前(Pages Router):

// pages/blog/[id].tsx
export async function getStaticPaths() {
  return {
    paths: [
      { params: { id: '1' } },
      { params: { id: '2' } },
    ],
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  return { props: { id: params.id } };
}

之后(App Router):

// app/blog/[id]/page.tsx
export async function generateStaticParams() {
  return [
    { id: '1' },
    { id: '2' },
  ];
}

export const dynamicParams = false; // equivalent to fallback: false

export default function BlogPost({ params }: { params: { id: string } }) {
  return <div>Post {params.id}</div>;
}
应避免的常见错误

❌ 错误:使用 'use client'

'use client'; // ERROR! generateStaticParams only works in Server Components

export async function generateStaticParams() {
  return [{ id: '1' }];
}

❌ 错误:使用 Pages Router 模式

export async function getStaticPaths() { // Wrong API!
  return { paths: [...], fallback: false };
}

❌ 错误:缺少 export 关键字

async function generateStaticParams() { // Must be exported!
  return [{ id: '1' }];
}

✅ 正确:简洁的服务端组件

// app/blog/[id]/page.tsx
// No 'use client' directive

export async function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }];
}

export default function Page({ params }: { params: { id: string } }) {
  return <div>Post {params.id}</div>;
}

关键实现说明:

当被要求“编写”或“实现” generateStaticParams 时:

  • 务必使用 Edit 或 Write 工具修改实际文件
  • 务必将函数添加到现有的 page.tsx 文件
  • 务必删除所有有关 generateStaticParams 的 TODO 注释
  • 不要只在 Markdown 中输出代码——应实际实现
  • 不要在不写入文件的情况下展示代码

测试与验证

迁移到 App Router 或使用它构建时,请验证:

  1. 结构:

    • 存在 app/ 目录
    • 存在包含 <html> 和 <body> 的根 layout.tsx
    • 每个路由都有一个 page.tsx 文件
  2. 元数据:

    • App Router 中没有 next/head 导入
    • 从页面或布局导出元数据
    • 元数据使用 Metadata 类型正确声明类型
  3. 导航:

    • 使用来自 next/link 的 Link 组件
    • 内部导航不使用普通的 <a> 标签
  4. 清理:

    • pages/ 目录中没有残留的页面文件
    • 已移除 _app.tsx 和 _document.tsx
    • 已移除旧的元数据模式

快速参考

文件结构映射
Pages RouterApp Router用途
pages/index.tsxapp/page.tsx首页路由
pages/about.tsxapp/about/page.tsx关于页面路由
pages/[id].tsxapp/[id]/page.tsx动态路由
pages/_app.tsxapp/layout.tsx全局布局
pages/_document.tsxapp/layout.tsxHTML 结构
pages/api/hello.tsapp/api/hello/route.tsAPI 路由
常用命令
# Create new Next.js app with App Router
npx create-next-app@latest my-app

# Run development server
npm run dev

# Build for production
npm run build

# Start production server
npm start

其他资源

有关更高级的路由模式(并行路由、拦截路由、路由处理器),请参阅 nextjs-advanced-routing 技能。

有关服务端与客户端组件的最佳实践和反模式,请参阅 nextjs-server-client-components 和 nextjs-anti-patterns 技能。

评论

支持 Markdown