关于 nextjs-dynamic-routes-params
面向 Next.js 开发者的动态路由技能,介绍如何按 URL 路径段组织路由并读取参数。涵盖服务端与客户端参数访问、Next.js 15+ 的异步 params,以及 catch-all 路由。
主要功能
- 01创建动态路由段
- 02读取服务端 params
- 03使用 useParams() 读取客户端参数
- 04支持 catch-all 与可选 catch-all
- 05为 Next.js 15+ 等待 params
适用场景
- 01按 URL 标识符加载详情页
- 02构建带 slug 的文章页面
- 03实现嵌套文档路径
- 04避免动态路由结构过度嵌套
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wsimmonds/ claude-nextjs-skills --skill nextjs-dynamic-routes-params安装后在 Claude Code 中输入/nextjs-dynamic-routes-params确认已加载。
详细介绍
Next.js 动态路由和路径名参数
何时使用此技能
适用于以下场景:
- 创建动态路由段(例如,blog/[slug]、users/[id])
- 在服务器组件或客户端组件中访问 URL 路径名参数
- 构建基于路由参数获取数据的页面
- 实现全匹配或可选全匹配路由
- 在 page.tsx、layout.tsx 或 route.ts 中使用
params属性
⚠️ 识别何时需要动态路由
留意数据与 URL 路径相关联的需求。
只要 UI 依赖路径名的一部分,就应创建动态段([param])。常见信号包括:
- 详情页会引用“URL 中的项目 ID/slug”
- 文案中提到了路径段(例如,
/products/{id}、/blog/{slug}) - 需要“根据当前访问的资源”获取数据
- 导航流程中,某个页面会链接到
/something/{identifier}
✅ 动态路由响应
Requirement: display product information based on whichever ID appears in the URL
Implementation: app/[id]/page.tsx
Access parameter with: const { id } = await params;
❌ 静态页面响应
Implementation: app/page.tsx ← cannot access per-path identifiers
会引出动态路由的需求示例
- “显示一个产品页面,根据 URL 中的产品 ID 加载对应产品” →
app/[id]/page.tsx或app/products/[id]/page.tsx - “根据 slug 渲染博客文章” →
app/blog/[slug]/page.tsx或app/[slug]/page.tsx - “支持嵌套文档,例如 /docs/getting-started/installation” →
app/docs/[...slug]/page.tsx
核心规则: 如果数据会随 URL 段变化,文件夹名称就必须使用相应的方括号。
⚠️ 重要:避免过度设计路由结构
最常见的错误: 为路由添加不必要的嵌套。
默认规则: 创建动态路由时,使用 app/[id]/page.tsx 或 app/[slug]/page.tsx,除非:
- URL 结构有明确规定(例如,“在 /products/[id] 创建路由”)
- 你正在构建多个需要使用命名空间的资源类型
- 需求明确要求嵌套 URL 结构
不要根据资源名称推断嵌套结构:
- “按 ID 获取产品” →
app/[id]/page.tsx✅(而不是app/products/[id]) - “显示用户个人资料” →
app/[userId]/page.tsx✅(而不是app/users/[userId]) - “显示博客文章” →
app/[slug]/page.tsx✅(而不是app/blog/[slug])
仅在明确要求时嵌套:
- “在 /blog/[slug] 创建路由” →
app/blog/[slug]/page.tsx✅ - “产品页面应位于 /products/[id]” →
app/products/[id]/page.tsx✅
核心概念
动态路由语法
Next.js 使用带方括号的文件夹名称创建动态路由段:
app/
├── [id]/page.tsx # Matches /123, /abc, etc.
├── blog/[slug]/page.tsx # Matches /blog/hello-world
├── shop/[category]/[id]/page.tsx # Matches /shop/electronics/123
└── docs/[...slug]/page.tsx # Matches /docs/a, /docs/a/b, /docs/a/b/c
**核心原则:**文件夹结构就是路由结构。
路由结构决策树
关键规则:不要根据资源类型名称推断路由结构!
仅仅因为你要获取一个“产品”或“用户”,并不意味着你需要 /products/[id] 或 /users/[id]。除非有明确要求,否则优先选择最简单的结构。
决定路由结构时:
-
顶层动态路由(
app/[id]/page.tsx)- 默认选择——除非有明确要求,否则使用此结构
- 当资源本身就是主要实体时使用
- 当只需要基于 ID 的路由时使用
- 示例:任何资源都可以使用
/123,slug 可以使用/abc-def - 模式:ID/slug 是唯一需要的标识符
- 不确定时,就选这个!
-
嵌套动态路由(
app/category/[id]/page.tsx)- 仅在 URL 结构明确要求时使用
- 当收到“创建一个
/products/[id]路由”这样的要求时使用 - 当 URL 本身需要包含类别前缀时使用
- 示例:
/products/123、/blog/my-post(在明确指定时) - 模式:类别 + 标识符(两者都必需时)
-
多段动态路由(
app/[cat]/[id]/page.tsx)- 当层级关系很重要时使用
- 示例:
/shop/electronics/123 - 模式:多个分类层级
⚠️ 常见错误: 应该创建 app/[id]/page.tsx 时,却创建了 app/products/[id]/page.tsx
❌ 错误:“通过 ID 获取产品” → app/products/[id]/page.tsx
✅ 正确:“通过 ID 获取产品” → app/[id]/page.tsx
❌ 错误:“为用户创建动态路由” → app/users/[userId]/page.tsx
✅ 正确:“为用户创建动态路由” → app/[userId]/page.tsx
仅在以下情况下添加类别前缀:
- 需求明确指出“位于
/products/...”或类似路径 - 你正在构建多个需要命名空间的资源类型
- 需求中指定了 URL 结构
访问路径名参数
在服务端组件中(page.tsx、layout.tsx)
关键:在 Next.js 15+ 中,params 是一个 Promise,必须使用 await!
// ✅ CORRECT - Next.js 15+
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res => res.json());
return <div>{product.name}</div>;
}
// ❌ WRONG - Treating params as synchronous object (Next.js 15+)
export default async function ProductPage({
params,
}: {
params: { id: string }; // Missing Promise wrapper
}) {
const product = await fetch(`https://api.example.com/products/${params.id}`);
// This will fail because params is a Promise!
}
对于 Next.js 14 及更早版本:
// Next.js 14 - params is synchronous
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then(res => res.json());
return <div>{product.name}</div>;
}
在路由处理程序中(route.ts)
// app/api/products/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const product = await db.products.findById(id);
return Response.json(product);
}
在客户端组件中
你无法在客户端组件中直接访问 params。 请改用以下方法:
- 使用
useParams()钩子:
'use client';
import { useParams } from 'next/navigation';
export function ProductClient() {
const params = useParams<{ id: string }>();
const id = params.id;
// Use the id...
}
- 从服务端组件传递 params:
// app/products/[id]/page.tsx (Server Component)
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <ProductClient productId={id} />;
}
// components/ProductClient.tsx
'use client';
export function ProductClient({ productId }: { productId: string }) {
// Use productId...
}
常见模式
模式 1:简单的基于 ID 的页面
// app/[id]/page.tsx - Top-level dynamic route
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ItemPage({ params }: PageProps) {
const { id } = await params;
const item = await fetch(`https://api.example.com/items/${id}`)
.then(res => res.json());
return (
<div>
<h1>{item.title}</h1>
<p>{item.description}</p>
</div>
);
}
模式 2:带 slug 的博客文章
// app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
模式 3:嵌套资源
// app/users/[userId]/posts/[postId]/page.tsx
interface PageProps {
params: Promise<{
userId: string;
postId: string;
}>;
}
export default async function UserPost({ params }: PageProps) {
const { userId, postId } = await params;
const [user, post] = await Promise.all([
getUserById(userId),
getPostById(postId),
]);
return (
<div>
<h1>{post.title}</h1>
<p>By {user.name}</p>
<div>{post.content}</div>
</div>
);
}
模式 4:全捕获路由
// app/docs/[...slug]/page.tsx - Matches /docs/a, /docs/a/b, /docs/a/b/c
interface PageProps {
params: Promise<{
slug: string[]; // Array of path segments
}>;
}
export default async function DocsPage({ params }: PageProps) {
const { slug } = await params;
const path = slug.join('/');
const doc = await getDocByPath(path);
return <div>{doc.content}</div>;
}
// app/shop/[[...slug]]/page.tsx - Optional catch-all
// Matches /shop, /shop/electronics, /shop/electronics/phones
interface PageProps {
params: Promise<{
slug?: string[]; // Optional array
}>;
}
export default async function ShopPage({ params }: PageProps) {
const { slug = [] } = await params;
if (slug.length === 0) {
return <ShopHomepage />;
}
return <CategoryPage category={slug.join('/')} />;
}
TypeScript 最佳实践
params 的类型安全
// Define params type separately for reusability
type ProductPageParams = { id: string };
interface ProductPageProps {
params: Promise<ProductPageParams>;
}
export default async function ProductPage({ params }: ProductPageProps) {
const { id } = await params;
// id is typed as string
}
// Reuse in generateMetadata
export async function generateMetadata({ params }: ProductPageProps) {
const { id } = await params;
const product = await getProduct(id);
return { title: product.name };
}
多个动态片段
type PostPageParams = {
category: string;
slug: string;
};
interface PostPageProps {
params: Promise<PostPageParams>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function PostPage({ params, searchParams }: PostPageProps) {
const { category, slug } = await params;
const { view } = await searchParams;
// All properly typed
}
常见问题与解决方案
问题 1:忘记 params 是一个 Promise(Next.js 15+)
// ❌ WRONG
export default async function Page({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // Error: params is Promise
}
// ✅ CORRECT
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
}
问题 2:在客户端组件中使用 params,却没有使用 useParams
// ❌ WRONG - params prop doesn't exist in Client Components
'use client';
export default function ClientPage({ params }) { // undefined!
return <div>{params.id}</div>;
}
// ✅ CORRECT
'use client';
import { useParams } from 'next/navigation';
export default function ClientPage() {
const params = useParams<{ id: string }>();
return <div>{params.id}</div>;
}
问题 3:路由嵌套过深
// ❌ UNNECESSARY NESTING
// app/products/product/[id]/page.tsx
// URL: /products/product/123
// ✅ SIMPLER
// app/products/[id]/page.tsx
// URL: /products/123
// OR even simpler if product is the main resource:
// app/[id]/page.tsx
// URL: /123
问题 4:未处理无效 ID
// ✅ ROBUST
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await fetch(`https://api.example.com/products/${id}`)
.then(res => {
if (!res.ok) throw new Error('Product not found');
return res.json();
});
if (!product) {
notFound(); // Shows 404 page
}
return <div>{product.name}</div>;
}
决策指南
需要创建动态路由时,请问自己:
-
URL 结构是什么?
- 单个 ID:
[id] - 类别 + ID:
category/[id] - 分层结构:
[category]/[id] - 灵活路径:
[...slug]
- 单个 ID:
-
这是服务端组件还是客户端组件?
- 服务端:使用
paramsprop(在 Next.js 15+ 中对其使用 await) - 客户端:使用
useParams()钩子
- 服务端:使用
-
是否需要最简单的结构?
- 不确定时,减少嵌套层级
- 顶层路由更简单、更直接
-
我使用的是 Next.js 15+ 吗?
- 是:
params的类型是Promise<{...}> - 否:
params的类型是{...}
- 是:
示例:实际场景
电商产品页面
// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
interface Product {
id: string;
name: string;
price: number;
description: string;
}
async function getProduct(id: string): Promise<Product | null> {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 }, // Revalidate every 60s
});
if (!res.ok) return null;
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</div>
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return {
title: product?.name ?? 'Product Not Found',
description: product?.description,
};
}
带嵌套路径的文档站点
// app/docs/[...slug]/page.tsx
interface DocPageProps {
params: Promise<{ slug: string[] }>;
}
export default async function DocPage({ params }: DocPageProps) {
const { slug } = await params;
const path = slug.join('/');
const doc = await getDocByPath(path);
if (!doc) {
notFound();
}
return (
<article className="prose">
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.html }} />
</article>
);
}
export async function generateStaticParams() {
const docs = await getAllDocs();
return docs.map((doc) => ({
slug: doc.path.split('/'),
}));
}
动态路由检查清单
实现动态路由之前,请确认:
- 路由结构尽可能简单(除非另有说明,否则默认使用顶层路由)
- 不根据资源类型名称推断路由嵌套关系
- 路由结构符合 URL 要求(避免过度嵌套)
- 对于 Next.js 15+,
params的类型为Promise<{...}> - 对于 Next.js 15+,访问属性前先等待
params - 处理无效 ID/slug 的错误(使用
notFound()) - 正确定义 TypeScript 类型
- 除非需要客户端交互,否则使用 Server Component
- 如果适用,考虑使用
generateStaticParams()进行 SSG - 如果 SEO 很重要,则生成元数据
快速参考
| 场景 | 路由结构 | 参数访问方式 |
|---|---|---|
| 按 ID 获取单个资源 | app/[id]/page.tsx | const { id } = await params |
| 类别 + 资源 | app/category/[id]/page.tsx | const { id } = await params |
| 带 slug 的博客 | app/blog/[slug]/page.tsx | const { slug } = await params |
| 嵌套资源 | app/[cat]/[id]/page.tsx | const { cat, id } = await params |
| 灵活路径 | app/docs/[...slug]/page.tsx | const { slug } = await params(slug 是数组) |
| 可选路径 | app/[[...slug]]/page.tsx | const { slug = [] } = await params |
| Client Component | 使用 useParams() hook | const params = useParams<{ id: string }>() |
请记住: Next.js 中的动态路由基于文件系统。使用 [brackets] 的文件夹结构会创建动态路由段,而 params 属性(或 useParams() hook)可用于访问这些值。