切换语言
切换主题

Next.js 动态路由与参数处理完全攻略:从入门到类型安全

引言

上周在重构一个 Next.js 项目时,我遇到了个让人抓狂的问题——明明按照文档写的动态路由,点进去却是 404。看着控制台一片安静,没有任何报错信息,我整个人都懵了。后来才发现,Next.js 14 的 App Router 把路由参数的获取方式改了,我还在用 Pages Router 的老写法。

说实话,这不是我第一次在 Next.js 路由上栽跟头。从 Pages Router 的 getStaticPaths 到 App Router 的 generateStaticParams,每次升级都得重新学一遍。什么时候该用动态路由,什么时候该用 catch-all 路由,可选参数又是怎么回事?这些概念混在一起,真的容易搞糊涂。

如果你也跟我一样,对 Next.js 的动态路由感到困惑,或者正在从 Pages Router 迁移到 App Router,那这篇文章就是为你准备的。我会从最基础的动态路由讲起,一直讲到类型安全的实践技巧,用大量实际代码示例帮你理清思路。

学完后你能得到什么?一套完整的动态路由知识体系,知道各种场景该用什么路由类型,如何正确获取参数,以及如何用 TypeScript 让路由参数也有类型提示。不玩虚的,就是实打实的代码和解决方案。咱们开始吧。

第一章:动态路由基础(从最简单的开始)

什么是动态路由?

先说个最常见的场景:你有个博客网站,每篇文章的 URL 是 /blog/文章ID。如果用静态路由,你得为每篇文章创建一个单独的页面文件,这显然不现实。这时候就需要动态路由——一个页面文件处理所有文章详情。

在 Next.js App Router 中,动态路由通过方括号命名的文件夹来实现。听起来有点绕,直接看例子:

app/
├── blog/
│   └── [slug]/
│       └── page.tsx    ← 这就是动态路由

这个结构会匹配所有 /blog/* 路径,比如:

  • /blog/hello-worldslug = "hello-world"
  • /blog/nextjs-guideslug = "nextjs-guide"
  • /blog/123slug = "123"

最简单的动态路由实现

创建 app/blog/[slug]/page.tsx,写下这段代码:

// app/blog/[slug]/page.tsx
export default function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  return (
    <div>
      <h1>文章详情</h1>
      <p>当前文章 slug: {params.slug}</p>
    </div>
  )
}

就这么简单!当用户访问 /blog/hello-world 时,params.slug 就是 "hello-world"

新手容易犯的错误

  1. ❌ 文件名用 [slug].tsx(App Router 需要用文件夹)
  2. ❌ 直接访问 props.slug(要通过 params 对象获取)
  3. ❌ 忘记文件夹名的方括号(没方括号就是静态路由了)

Pages Router vs App Router 对比

如果你之前用过 Pages Router,可能会觉得奇怪:“以前不是在 pages/blog/[slug].tsx 里写吗?“是的,App Router 改了不少东西:

特性Pages RouterApp Router
文件位置pages/blog/[slug].tsxapp/blog/[slug]/page.tsx
参数获取router.query.sluggetStaticPropsparams.slug
类型定义需手动定义通过 props 类型推导
静态生成getStaticPathsgenerateStaticParams

我刚开始迁移时,最不习惯的就是参数获取方式。Pages Router 可以用 useRouter hook,App Router 的 Server Components 不能用 hooks,只能通过 params prop。这是因为 Server Components 默认在服务端渲染,没有客户端的 router 对象。

实战案例:电商产品详情页

假设你在做个电商网站,产品详情页的 URL 是 /products/产品ID。完整实现是这样的:

// app/products/[id]/page.tsx
interface Product {
  id: string
  name: string
  price: number
  description: string
}

// 模拟从数据库获取产品
async function getProduct(id: string): Promise<Product | null> {
  // 实际项目中这里是数据库查询或 API 调用
  const products: Product[] = [
    { id: '1', name: 'TypeScript 入门书', price: 99, description: '适合初学者' },
    { id: '2', name: 'React 实战指南', price: 129, description: '从零到项目上线' }
  ]
  return products.find(p => p.id === id) || null
}

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    return <div>产品不存在</div>
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p className="price">¥{product.price}</p>
      <p>{product.description}</p>
    </div>
  )
}

注意这几个细节

  1. 组件用了 async,因为 Server Components 支持异步
  2. 先获取数据,再根据结果决定渲染什么
  3. 处理了产品不存在的情况(404 场景)

如果想返回真正的 404 页面,可以用 Next.js 的 notFound 函数:

import { notFound } from 'next/navigation'

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  const product = await getProduct(params.id)

  if (!product) {
    notFound() // 返回 404 页面
  }

  return (
    <div>
      <h1>{product.name}</h1>
      {/* ... */}
    </div>
  )
}

这样用户访问不存在的产品时,会看到你自定义的 not-found.tsx 页面,体验更好。

到这里,你已经掌握了基础的动态路由。但这只是冰山一角,接下来我们看看更复杂的场景——当你需要匹配多层路径时该怎么办。

第二章:Catch-All 路由与可选参数(处理复杂路径)

什么时候需要 Catch-All 路由?

假设你在做个文档网站,URL 结构是这样的:

  • /docs/getting-started
  • /docs/api/authentication
  • /docs/api/database/queries
  • /docs/guides/deployment/vercel

路径层级不固定,可能是 2 层,也可能是 3 层或更多。用普通动态路由搞不定,这时候就需要 Catch-All 路由

Catch-All 路由:[...slug]

文件夹命名用 [...slug](三个点),可以匹配任意层级的路径:

app/
├── docs/
│   └── [...slug]/
│       └── page.tsx    ← 匹配 /docs/* 下所有路径

这会匹配:

  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authenticationslug = ["api", "authentication"]
  • /docs/guides/deployment/vercelslug = ["guides", "deployment", "vercel"]

注意slug 参数是个数组,不是字符串!

代码实现:文档系统

// app/docs/[...slug]/page.tsx
interface Doc {
  title: string
  content: string
}

// 根据路径数组获取文档
async function getDoc(slugArray: string[]): Promise<Doc | null> {
  // 把数组拼成路径,比如 ["api", "auth"] → "api/auth"
  const path = slugArray.join('/')

  // 实际项目中从文件系统或数据库读取
  const docs: Record<string, Doc> = {
    'getting-started': {
      title: '快速开始',
      content: '欢迎使用我们的产品...'
    },
    'api/authentication': {
      title: 'API 认证',
      content: '我们使用 JWT 进行认证...'
    },
    'api/database/queries': {
      title: '数据库查询',
      content: '使用 Prisma 查询数据库...'
    }
  }

  return docs[path] || null
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  const doc = await getDoc(params.slug)

  if (!doc) {
    return <div>文档不存在</div>
  }

  return (
    <article>
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.content }} />

      {/* 面包屑导航 */}
      <nav>
        <a href="/docs">文档</a>
        {params.slug.map((segment, i) => {
          const href = `/docs/${params.slug.slice(0, i + 1).join('/')}`
          return (
            <span key={i}>
              {' / '}
              <a href={href}>{segment}</a>
            </span>
          )
        })}
      </nav>
    </article>
  )
}

这段代码的亮点

  1. slugArray.join('/') 把路径数组拼成字符串
  2. 实现了面包屑导航,用 slice 截取路径前缀
  3. 类型标注 params: { slug: string[] },TypeScript 会检查你没写错

可选 Catch-All 路由:[[...slug]]

有时候你希望既匹配 /docs,又匹配 /docs/*。普通 Catch-All 路由不会匹配 /docs(没有参数),这时候用 可选 Catch-All 路由

app/
├── docs/
│   └── [[...slug]]/
│       └── page.tsx    ← 注意是双层方括号

这会匹配:

  • /docsslug = undefined
  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authslug = ["api", "auth"]

代码里需要处理 slug 可能是 undefined 的情况:

// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
  params
}: {
  params: { slug?: string[] }  // 注意 slug 是可选的
}) {
  // 如果是 /docs 首页
  if (!params.slug) {
    return <div>欢迎来到文档中心</div>
  }

  // 处理子路径
  const doc = await getDoc(params.slug)
  // ...
}

新手容易踩的坑

坑1:忘记 slug 是数组

// ❌ 错误写法
<h1>当前路径: {params.slug}</h1>  // 会显示 "api,authentication"

// ✅ 正确写法
<h1>当前路径: {params.slug.join('/')}</h1>  // 显示 "api/authentication"

坑2:静态生成时数据结构不对

// ❌ 错误写法
export function generateStaticParams() {
  return [
    { slug: 'api/auth' }  // 这是字符串,不是数组!
  ]
}

// ✅ 正确写法
export function generateStaticParams() {
  return [
    { slug: ['api', 'auth'] }  // 数组形式
  ]
}

坑3:混淆普通动态路由和 Catch-All 路由

路由类型文件夹命名匹配范围参数类型
动态路由[slug]/blog/123string
Catch-All[...slug]/docs/a/b/c(不含 /docsstring[]
可选 Catch-All[[...slug]]/docs/docs/a/b/cstring[] | undefined

我当时就是把这三种混在一起用,结果路由一会儿能访问一会儿不能,查了半天才发现是文件夹命名写错了。

实战技巧:处理特殊字符

如果 URL 里有中文或特殊字符,记得做编解码:

export default async function Page({
  params
}: {
  params: { slug: string[] }
}) {
  // URL 会自动编码,需要解码才能正常显示
  const decodedSlug = params.slug.map(s => decodeURIComponent(s))

  console.log(params.slug)      // ["api", "%E8%AE%A4%E8%AF%81"]
  console.log(decodedSlug)      // ["api", "认证"]

  // ...
}

到这里,你已经能处理各种复杂的路径结构了。但还有个关键问题没解决:这些动态页面什么时候生成?每次请求都渲染一遍,还是构建时提前生成好?这就是下一章要讲的 generateStaticParams

第三章:generateStaticParams 深度解析(何时用、怎么用)

为什么需要 generateStaticParams?

假设你的博客有 100 篇文章,每篇文章都是动态路由 /blog/[slug]。如果不做优化,用户每次访问都要:

  1. 查询数据库获取文章内容
  2. 服务端渲染 HTML
  3. 返回给用户

这样响应慢,服务器压力大。Next.js 提供了更好的方案——在构建时预渲染所有文章页面,生成静态 HTML。这就是 generateStaticParams 的作用。

基础用法:静态生成博客文章

// app/blog/[slug]/page.tsx
interface Post {
  slug: string
  title: string
  content: string
}

// 获取所有文章的 slug
export async function generateStaticParams() {
  // 从数据库或 CMS 获取所有文章
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())

  // 返回所有可能的参数组合
  return posts.map((post: Post) => ({
    slug: post.slug
  }))
}

// 渲染文章详情
export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  // 根据 slug 获取文章内容
  const post = await fetch(`https://api.example.com/posts/${params.slug}`)
    .then(r => r.json())

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

这段代码做了什么?

  1. generateStaticParams 在构建时运行,返回所有文章的 slug
  2. Next.js 为每个 slug 预渲染一个静态 HTML 文件
  3. 用户访问时直接返回静态文件,超级快

构建后的产物

.next/server/app/blog/
├── hello-world.html
├── nextjs-guide.html
└── typescript-tips.html

何时用 generateStaticParams?

这是我被问得最多的问题。简单判断:

适合用 generateStaticParams 的场景

  • 博客文章、新闻详情(内容相对固定)
  • 产品详情页(产品数量有限,比如 < 10000)
  • 文档页面、帮助中心
  • 用户个人主页(如果用户量不大)

不适合用的场景

  • 搜索结果页(参数组合无限)
  • 实时数据(股票行情、体育比分)
  • 用户量巨大的 UGC 平台(不可能预渲染所有用户页面)
  • 需要根据登录状态显示不同内容的页面

进阶用法1:Catch-All 路由的静态生成

对于 [...slug] 路由,返回的参数要是数组:

// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
  // 所有文档路径
  const docPaths = [
    ['getting-started'],
    ['api', 'authentication'],
    ['api', 'database', 'queries'],
    ['guides', 'deployment', 'vercel']
  ]

  return docPaths.map(slug => ({ slug }))
}

export default async function DocsPage({
  params
}: {
  params: { slug: string[] }
}) {
  // ...
}

注意:返回的格式是 { slug: ['api', 'auth'] },不是 { slug: 'api/auth' }

进阶用法2:多参数路由

如果路由有多个动态参数,比如 /shop/[category]/[productId]

app/
├── shop/
│   └── [category]/
│       └── [productId]/
│           └── page.tsx

generateStaticParams 这样写:

// app/shop/[category]/[productId]/page.tsx
export async function generateStaticParams() {
  const products = [
    { category: 'electronics', productId: 'iphone-15' },
    { category: 'electronics', productId: 'macbook-pro' },
    { category: 'books', productId: 'clean-code' },
    { category: 'books', productId: 'refactoring' }
  ]

  return products.map(p => ({
    category: p.category,
    productId: p.productId
  }))
}

export default async function ProductPage({
  params
}: {
  params: { category: string; productId: string }
}) {
  return (
    <div>
      <h1>分类: {params.category}</h1>
      <p>产品 ID: {params.productId}</p>
    </div>
  )
}

进阶用法3:按需生成(fallback 模式)

如果你的内容太多(比如 10 万篇文章),全部预渲染不现实。可以只生成热门内容,其余的按需生成:

// app/blog/[slug]/page.tsx
export const dynamicParams = true  // 允许动态生成未预渲染的页面

export async function generateStaticParams() {
  // 只预渲染前 100 篇热门文章
  const topPosts = await fetchTopPosts(100)

  return topPosts.map(post => ({
    slug: post.slug
  }))
}

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  // 即使没预渲染,第一次访问也会生成页面并缓存
  const post = await fetchPost(params.slug)

  if (!post) {
    notFound()
  }

  return <article>{/* ... */}</article>
}

设置 dynamicParams = true 后:

  • 预渲染的页面:立即返回(最快)
  • 未预渲染的页面:第一次请求时生成,后续访问复用缓存
  • 不存在的页面:返回 404

新手容易卡的地方

问题1:generateStaticParams 什么时候运行?

只在构建时npm run build)运行,不是每次请求都运行。所以开发环境(npm run dev)看不到效果,必须构建后才能看到静态生成的文件。

问题2:数据更新了怎么办?

静态生成后,内容就固定了。如果数据更新,需要重新构建并部署。解决方案:

  • 使用 ISR(Incremental Static Regeneration)定时更新
  • 结合 dynamicParams = true 按需更新
  • 使用 revalidate 设置缓存过期时间
// 每隔 60 秒重新生成页面
export const revalidate = 60

export default async function Page() {
  // ...
}

问题3:为什么构建时间变长了?

generateStaticParams 返回的路径越多,构建时间越长。如果构建超时:

  • 减少预渲染的页面数量(只渲染热门内容)
  • 使用增量构建(Vercel/Netlify 支持)
  • 考虑按需生成(dynamicParams = true

到这里,你已经掌握了 Next.js 动态路由的核心用法。最后一章,我们解决一个困扰很多人的问题——如何让路由参数也有 TypeScript 类型提示?

第四章:路由参数类型安全实践(告别 any

为什么需要类型安全?

看这段代码,你能发现问题吗?

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  // 假设这是个数字 ID,但类型定义是 string
  const id = parseInt(params.slug)

  if (isNaN(id)) {
    // 运行时才发现类型不对!
    return <div>无效的 ID</div>
  }

  // ...
}

问题是:params.slug 类型是 string,但你实际需要的是数字。这种类型不匹配,编译时发现不了,只能运行时报错。

基础类型约束

Next.js 的 params 对象默认所有参数都是 stringstring[]。你可以自定义类型来增强约束:

// app/blog/[slug]/page.tsx
interface BlogParams {
  slug: string
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  // TypeScript 知道 params.slug 是 string
  const post = await fetchPost(params.slug)
  // ...
}

这看起来没多大用,但当参数多了就很有价值:

// app/shop/[category]/[productId]/page.tsx
interface ShopParams {
  category: 'electronics' | 'books' | 'clothing'  // 限定为几个值
  productId: string
}

export default async function ProductPage({
  params
}: {
  params: ShopParams
}) {
  // TypeScript 会检查 category 是否是允许的值
  if (params.category === 'toys') {  // ❌ 编译错误!
    // ...
  }
}

运行时验证:结合 Zod

类型定义只能在编译时检查,运行时还是可能传入非法值。结合 Zod 做运行时验证更安全:

npm install zod
// app/products/[id]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'

// 定义参数的 schema
const paramsSchema = z.object({
  id: z.string().regex(/^\d+$/, '必须是数字 ID')
})

export default async function ProductPage({
  params
}: {
  params: { id: string }
}) {
  // 运行时验证
  const result = paramsSchema.safeParse(params)

  if (!result.success) {
    notFound()  // 非法参数直接返回 404
  }

  const { id } = result.data
  const product = await fetchProduct(parseInt(id))
  // ...
}

这样做的好处:

  • 编译时有类型检查
  • 运行时验证参数格式
  • 非法请求直接返回 404,不会查询数据库

高级技巧:类型安全的 generateStaticParams

generateStaticParams 的返回值也可以加类型约束:

// app/blog/[slug]/page.tsx
interface BlogParams {
  slug: string
}

export async function generateStaticParams(): Promise<BlogParams[]> {
  const posts = await fetchAllPosts()

  return posts.map(post => ({
    slug: post.slug
    // 如果你写成 slug: post.id(类型不对),TypeScript 会报错
  }))
}

export default async function BlogPost({
  params
}: {
  params: BlogParams
}) {
  // ...
}

实战案例:多语言博客路由

假设你在做个多语言博客,URL 是 /[locale]/blog/[slug],比如:

  • /zh/blog/hello-world
  • /en/blog/hello-world

完整的类型安全实现:

// app/[locale]/blog/[slug]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'

// 支持的语言列表
const locales = ['zh', 'en', 'ja'] as const
type Locale = typeof locales[number]  // "zh" | "en" | "ja"

interface PageParams {
  locale: Locale
  slug: string
}

// 运行时验证 schema
const paramsSchema = z.object({
  locale: z.enum(locales),
  slug: z.string().min(1)
})

export async function generateStaticParams(): Promise<PageParams[]> {
  const posts = await fetchAllPosts()

  // 为每个语言生成对应的路径
  return locales.flatMap(locale =>
    posts.map(post => ({
      locale,
      slug: post.slug
    }))
  )
}

export default async function BlogPost({
  params
}: {
  params: PageParams
}) {
  // 运行时验证
  const result = paramsSchema.safeParse(params)
  if (!result.success) {
    notFound()
  }

  const { locale, slug } = result.data

  // 获取对应语言的文章
  const post = await fetchPost(slug, locale)

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

这段代码的优点

  1. Locale 类型限定为 "zh" | "en" | "ja",写错会报错
  2. generateStaticParams 返回类型是 PageParams[],确保结构正确
  3. 运行时用 Zod 验证,防止非法请求
  4. 整个流程从类型定义到运行时验证都是严格的

常见类型问题排查

问题1:params 类型是 Promise<...> 怎么办?

Next.js 15 之后,params 可能是异步的。需要这样写:

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

或者用同步版本(如果你确定是 Next.js 14):

export default async function Page({
  params
}: {
  params: { slug: string }
}) {
  // 直接用
}

问题2:类型提示不准确

如果 TypeScript 提示 paramsany,检查:

  1. tsconfig.json 是否启用了严格模式
  2. 是否正确导入了 Next.js 的类型
  3. 文件命名是否正确(必须是 page.tsx

问题3:Zod 验证失败但想看详细错误

const result = paramsSchema.safeParse(params)

if (!result.success) {
  console.error('参数验证失败:', result.error.format())
  notFound()
}

类型安全检查清单

在你的项目里,检查这几点确保类型安全:

  • 所有动态路由页面都定义了 params 类型
  • generateStaticParams 返回值类型与 params 一致
  • 关键路由使用了运行时验证(Zod)
  • 启用了 TypeScript 严格模式
  • 复杂参数使用了联合类型或字面量类型

做到这些,你的路由系统就基本不会出类型相关的 bug 了。

结论

如果你跟着这篇文章学到这里,恭喜你!你现在已经掌握了 Next.js 动态路由的完整知识体系。让我们回顾一下你学到了什么:

基础动态路由:用 [slug] 匹配单层路径,理解 params 参数获取方式
Catch-All 路由:用 [...slug] 处理多层路径,知道可选参数的用法
generateStaticParams:理解何时用、怎么用,以及按需生成的策略
类型安全实践:从编译时类型约束到运行时验证的完整方案

更重要的是,你理解了 App Router 和 Pages Router 的区别,不会再混淆两者的用法。你也知道了什么时候该预渲染,什么时候该按需生成,能根据实际场景选择合适的方案。

接下来可以做什么?

立即实践(不要拖延):

  • 在你的项目里创建一个动态路由,试试 params 参数获取
  • 如果有多层路径需求,尝试 Catch-All 路由
  • 给你的路由加上 TypeScript 类型定义和 Zod 验证

进阶学习(深入掌握):

  • 并行路由:在同一个页面加载多个路由(@folder 语法)
  • 拦截路由:在不离开当前页面的情况下显示另一个路由((.)folder 语法)
  • 路由组:用 (folder) 组织路由但不影响 URL 结构
  • 中间件:在路由级别做权限控制和重定向

学习资源(官方最权威):

常见问题速查

问题检查项解决方案
访问动态路由 404文件夹命名、generateStaticParams确认方括号正确,检查静态生成配置
paramsanyTypeScript 配置启用严格模式,定义参数类型
构建时间太长generateStaticParams 返回数量减少预渲染页面,使用按需生成
数据不更新缓存策略设置 revalidatedynamicParams

最后想说的话

Next.js 的路由系统从 Pages Router 到 App Router 经历了很大变化,我知道很多人(包括我自己)都经历过迁移的阵痛。但一旦掌握了 App Router 的思维方式,你会发现它其实更直观、更强大。

动态路由只是 Next.js 的一个方面,但它是整个应用的基础。把路由搞明白了,后面的数据获取、缓存策略、中间件等概念学起来会顺畅很多。

如果你在实践中遇到问题:

  1. 先查官方文档的”Troubleshooting”章节
  2. 在 Next.js GitHub 仓库搜索相关 Issue
  3. 到 Next.js Discord 社区提问(英文,但响应很快)

别害怕试错,我当时也是折腾了好几个项目才完全理解 App Router 的路由机制。现在你有了这篇文章作为参考,应该能少走很多弯路。

现在,打开你的编辑器,开始构建你的动态路由吧!🚀

Next.js 动态路由配置完整流程

从创建动态路由到类型安全实践的完整步骤

⏱️ 预计耗时: 2 小时

  1. 1

    步骤1: 创建动态路由文件夹

    根据需求选择路由类型:
    • 单参数:app/posts/[id]/page.tsx
    • 多参数:app/posts/[category]/[id]/page.tsx
    • Catch-all:app/posts/[...slug]/page.tsx
    • 可选catch-all:app/posts/[[...slug]]/page.tsx

    文件夹命名规则:
    • [id]:必需参数
    • [...slug]:捕获所有路径段
    • [[...slug]]:可选捕获所有路径段
  2. 2

    步骤2: 获取路由参数

    在page.tsx中获取参数:
    • App Router使用params对象
    • params是Promise,需要await
    • 使用解构获取具体参数

    示例:
    export default async function Page({ params }) {
    const { id } = await params
    return <div>Post {id}</div>
    }

    注意:params必须await,否则会报错
  3. 3

    步骤3: 配置类型安全

    使用TypeScript定义类型:
    • 定义params类型接口
    • 使用Promise<{ params }>类型
    • 使用generateStaticParams返回类型

    示例:
    interface PageProps {
    params: Promise<{ id: string }>
    }

    export default async function Page({ params }: PageProps) {
    const { id } = await params
    // ...
    }
  4. 4

    步骤4: 实现静态生成(可选)

    使用generateStaticParams:
    • 返回所有可能的参数组合
    • 支持async函数获取数据
    • 用于静态生成所有页面

    示例:
    export async function generateStaticParams() {
    const posts = await getPosts()
    return posts.map(post => ({ id: post.id }))
    }

    注意:只用于静态生成,动态路由不需要
  5. 5

    步骤5: 处理可选参数

    可选catch-all路由:
    • 使用[[...slug]]语法
    • params.slug可能是undefined
    • 需要检查参数是否存在

    示例:
    export default async function Page({ params }) {
    const { slug } = await params
    if (!slug) {
    return <div>All posts</div>
    }
    return <div>Category: {slug.join('/')}</div>
    }
  6. 6

    步骤6: 测试和验证

    测试要点:
    • 测试所有路由是否正常
    • 验证参数获取是否正确
    • 检查类型提示是否正常
    • 测试静态生成是否成功

    检查清单:
    • 所有动态路由都能正常访问
    • 参数类型定义正确
    • generateStaticParams返回正确数据
    • 404错误已处理

常见问题

动态路由参数怎么获取?
App Router使用params对象获取参数。

关键点:
• params是Promise,必须await
• 使用解构获取具体参数
• 类型需要定义

示例:
export default async function Page({ params }) {
const { id } = await params
return <div>{id}</div>
}
为什么动态路由返回404?
可能原因:
• 文件夹命名错误(应该是[id]不是{id})
• 路径不匹配(检查URL和文件夹结构)
• generateStaticParams返回的数据不完整
• 缺少page.tsx文件

解决方法:
• 检查文件夹命名是否正确
• 确认URL路径与文件夹结构匹配
• 检查generateStaticParams返回值
catch-all 路由和可选 catch-all 有什么区别?
catch-all路由[...slug]:
• 必须匹配至少一个路径段
• /posts/[...slug] 匹配 /posts/a,但不匹配 /posts

可选catch-all路由[[...slug]]:
• 可以匹配0个或多个路径段
• /posts/[[...slug]] 匹配 /posts 和 /posts/a/b

使用场景:
• catch-all:需要至少一个参数
• 可选catch-all:参数可选
如何实现类型安全的动态路由?
步骤:
1) 定义params类型接口
2) 使用Promise<{ params }>类型
3) 使用generateStaticParams返回类型

示例:
interface PageProps {
params: Promise<{ id: string }>
}

export default async function Page({ params }: PageProps) {
const { id } = await params
// ...
}
generateStaticParams 什么时候用?
用于静态生成所有可能的页面。

适用场景:
• 知道所有可能的参数值
• 需要静态生成所有页面
• 提升性能和SEO

不适用场景:
• 参数值动态变化
• 参数值太多无法枚举
• 需要实时数据

注意:只用于静态生成,动态路由不需要
如何从 Pages Router 迁移动态路由?
主要变化:
• getStaticPaths → generateStaticParams
• context.params → params(需要await)
• 返回格式从{ paths, fallback }改为数组

迁移步骤:
1) 将getStaticPaths改为generateStaticParams
2) 修改参数获取方式(使用await params)
3) 更新类型定义
4) 测试所有路由
多参数动态路由怎么处理?
创建多层文件夹:
app/posts/[category]/[id]/page.tsx

获取参数:
export default async function Page({ params }) {
const { category, id } = await params
return <div>{category} - {id}</div>
}

generateStaticParams返回所有组合:
export async function generateStaticParams() {
return [
{ category: 'tech', id: '1' },
{ category: 'tech', id: '2' },
// ...
]
}

13 分钟阅读 · 发布于: 2025年12月25日 · 修改于: 2026年1月22日

评论

使用 GitHub 账号登录后即可评论

相关文章