mcpField
N
nextjs-routing-advanced

nextjs-routing-advanced

github.com/josiahsiegel开发5972 次安装19 周前更新
代码仓库

关于 nextjs-routing-advanced

面向采用 Next.js App Router 的开发者进阶路由参考技能,介绍动态与捕获所有路由、并行和拦截路由、导航及 Route Handler。

主要功能

  1. 01动态路由与捕获所有路由模式
  2. 02路由组与私有文件夹
  3. 03并行路由与拦截路由
  4. 04编程式导航方法
  5. 05HTTP Route Handler 模式

适用场景

  1. 01构建基于 slug 的博客或商品页面
  2. 02用并行路由组织仪表盘布局
  3. 03将图库条目作为模态路由打开
  4. 04使用 Route Handler 创建 API 端点

安装

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

$npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill nextjs-routing-advanced

安装后在 Claude Code 中输入/nextjs-routing-advanced确认已加载。

详细介绍

快速参考

路由类型文件夹模式URL 示例
动态[slug]/blog/hello → { slug: 'hello' }
全匹配[...slug]/docs/a/b → { slug: ['a', 'b'] }
可选全匹配[[...slug]]/shop 或 /shop/a/b
路由组(name)不影响 URL,用于布局分组
并行路由@slot独立加载/错误处理
拦截同级路由(.)path模态框模式
私有文件夹_folder不是路由
导航方式代码使用场景
Link<Link href="/path">声明式导航
router.pushrouter.push('/path')编程式导航
router.replacerouter.replace('/path')不添加历史记录
redirectredirect('/path')服务端重定向
路由处理器方法模式
GET读取export async function GET() {}
POST创建export async function POST() {}
PUT更新export async function PUT() {}
DELETE删除export async function DELETE() {}

何时使用此技能

适用于高级路由模式:

  • 使用 slug 的动态博客/产品页面
  • 使用全匹配路由的文档
  • 使用并行路由的仪表盘布局
  • 使用拦截路由的照片库模态框
  • 使用路由处理器的 API 端点

相关技能:

  • App Router 基础:参见 nextjs-app-router
  • 中间件路由:参见 nextjs-middleware
  • 路由中的数据:参见 nextjs-data-fetching

Next.js 高级路由

动态路由

单个动态段
// 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 getPost(slug);

  return <article>{post.content}</article>;
}

// /blog/hello-world → { slug: 'hello-world' }
多个动态段
// app/shop/[category]/[product]/page.tsx
interface PageProps {
  params: Promise<{ category: string; product: string }>;
}

export default async function ProductPage({ params }: PageProps) {
  const { category, product } = await params;
  const productData = await getProduct(category, product);

  return <div>{productData.name}</div>;
}

// /shop/electronics/laptop → { category: 'electronics', product: 'laptop' }
全匹配段
// app/docs/[...slug]/page.tsx
interface PageProps {
  params: Promise<{ slug: string[] }>;
}

export default async function DocsPage({ params }: PageProps) {
  const { slug } = await params;
  // slug is an array of path segments
  const doc = await getDoc(slug.join('/'));

  return <div>{doc.content}</div>;
}

// /docs/getting-started → { slug: ['getting-started'] }
// /docs/api/auth/login → { slug: ['api', 'auth', 'login'] }
可选全匹配段
// app/shop/[[...slug]]/page.tsx
interface PageProps {
  params: Promise<{ slug?: string[] }>;
}

export default async function ShopPage({ params }: PageProps) {
  const { slug } = await params;

  if (!slug) {
    // /shop - show all products
    return <AllProducts />;
  }

  if (slug.length === 1) {
    // /shop/category - show category
    return <CategoryProducts category={slug[0]} />;
  }

  // /shop/category/product - show product
  return <ProductDetail category={slug[0]} product={slug[1]} />;
}

// /shop → { slug: undefined }
// /shop/electronics → { slug: ['electronics'] }
// /shop/electronics/laptop → { slug: ['electronics', 'laptop'] }

路由组

组织路由且不影响 URL
app/
├── (marketing)/
│   ├── layout.tsx      # Marketing layout
│   ├── about/
│   │   └── page.tsx    # /about
│   └── contact/
│       └── page.tsx    # /contact
│
├── (shop)/
│   ├── layout.tsx      # Shop layout
│   ├── products/
│   │   └── page.tsx    # /products
│   └── cart/
│       └── page.tsx    # /cart
│
└── (auth)/
    ├── layout.tsx      # Auth layout (centered, minimal)
    ├── login/
    │   └── page.tsx    # /login
    └── register/
        └── page.tsx    # /register
多个根布局
// app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <MarketingHeader />
        {children}
        <MarketingFooter />
      </body>
    </html>
  );
}

// app/(app)/layout.tsx
export default function AppLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <AppSidebar />
        <main>{children}</main>
      </body>
    </html>
  );
}

并行路由

基本并行路由
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx
│   └── loading.tsx
├── @team/
│   ├── page.tsx
│   └── loading.tsx
└── @notifications/
    └── page.tsx
// app/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <main>{children}</main>
      <aside>
        {analytics}
        {team}
        {notifications}
      </aside>
    </div>
  );
}
使用并行路由进行条件渲染
// app/layout.tsx
import { auth } from '@/lib/auth';

export default async function Layout({
  children,
  admin,
  user,
}: {
  children: React.ReactNode;
  admin: React.ReactNode;
  user: React.ReactNode;
}) {
  const session = await auth();

  return (
    <div>
      {children}
      {session?.role === 'admin' ? admin : user}
    </div>
  );
}
默认插槽
// app/@analytics/default.tsx
// Shown when the slot doesn't match current route
export default function AnalyticsDefault() {
  return null; // or a default UI
}

拦截路由

模态框模式
app/
├── feed/
│   └── page.tsx              # /feed - main feed
├── photo/
│   └── [id]/
│       └── page.tsx          # /photo/123 - full page photo
└── @modal/
    └── (.)photo/
        └── [id]/
            └── page.tsx      # Intercepted: shows modal
拦截约定
(.)  - Match same level
(..) - Match one level above
(..)(..) - Match two levels above
(...) - Match from root
照片库模态框示例
// app/feed/page.tsx
import Link from 'next/link';

export default function FeedPage() {
  const photos = await getPhotos();

  return (
    <div className="grid">
      {photos.map((photo) => (
        <Link key={photo.id} href={`/photo/${photo.id}`}>
          <img src={photo.thumbnail} alt={photo.title} />
        </Link>
      ))}
    </div>
  );
}
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/modal';

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
      <p>{photo.description}</p>
    </Modal>
  );
}
// app/photo/[id]/page.tsx - Full page view (direct navigation)
export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await getPhoto(id);

  return (
    <div className="photo-page">
      <img src={photo.url} alt={photo.title} />
      <h1>{photo.title}</h1>
      <p>{photo.description}</p>
    </div>
  );
}
// app/layout.tsx
export default function RootLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {children}
        {modal}
      </body>
    </html>
  );
}
// components/modal.tsx
'use client';

import { useRouter } from 'next/navigation';

export function Modal({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  return (
    <div className="modal-overlay" onClick={() => router.back()}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button onClick={() => router.back()}>Close</button>
        {children}
      </div>
    </div>
  );
}

私有文件夹

app/
├── _components/         # Private - not a route
│   ├── Button.tsx
│   └── Card.tsx
├── _lib/               # Private - not a route
│   └── utils.ts
├── dashboard/
│   ├── _components/    # Private - scoped to dashboard
│   │   └── Chart.tsx
│   └── page.tsx
└── page.tsx

路由处理器

HTTP 方法
// app/api/posts/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const posts = await getPosts();
  return NextResponse.json(posts);
}

export async function POST(request: Request) {
  const body = await request.json();
  const post = await createPost(body);
  return NextResponse.json(post, { status: 201 });
}

export async function PUT(request: Request) {
  const body = await request.json();
  const post = await updatePost(body);
  return NextResponse.json(post);
}

export async function DELETE(request: Request) {
  await deletePost();
  return new NextResponse(null, { status: 204 });
}
动态路由处理器
// app/api/posts/[id]/route.ts
interface RouteContext {
  params: Promise<{ id: string }>;
}

export async function GET(request: Request, context: RouteContext) {
  const { id } = await context.params;
  const post = await getPost(id);

  if (!post) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }

  return NextResponse.json(post);
}
路由处理器选项
// Force dynamic
export const dynamic = 'force-dynamic';

// Set runtime
export const runtime = 'edge';

// Set revalidation
export const revalidate = 60;

URL 查询参数

访问搜索参数
// app/search/page.tsx
interface SearchPageProps {
  searchParams: Promise<{ q?: string; page?: string; sort?: string }>;
}

export default async function SearchPage({ searchParams }: SearchPageProps) {
  const { q, page = '1', sort = 'relevance' } = await searchParams;

  const results = await search({
    query: q,
    page: parseInt(page),
    sort,
  });

  return (
    <div>
      <h1>Results for: {q}</h1>
      <SearchResults results={results} />
    </div>
  );
}
客户端 URL 更新
'use client';

import { useRouter, useSearchParams, usePathname } from 'next/navigation';

export function SearchFilter() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const updateSearch = (key: string, value: string) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set(key, value);
    router.push(`${pathname}?${params.toString()}`);
  };

  return (
    <select onChange={(e) => updateSearch('sort', e.target.value)}>
      <option value="relevance">Relevance</option>
      <option value="date">Date</option>
      <option value="price">Price</option>
    </select>
  );
}

编程式导航

useRouter Hook
'use client';

import { useRouter } from 'next/navigation';

export function NavigationExample() {
  const router = useRouter();

  return (
    <div>
      <button onClick={() => router.push('/dashboard')}>
        Go to Dashboard
      </button>
      <button onClick={() => router.replace('/login')}>
        Replace with Login
      </button>
      <button onClick={() => router.back()}>
        Go Back
      </button>
      <button onClick={() => router.forward()}>
        Go Forward
      </button>
      <button onClick={() => router.refresh()}>
        Refresh
      </button>
      <button onClick={() => router.prefetch('/about')}>
        Prefetch About
      </button>
    </div>
  );
}
redirect() 函数
// In Server Component or Server Action
import { redirect } from 'next/navigation';

export default async function ProtectedPage() {
  const session = await getSession();

  if (!session) {
    redirect('/login');
  }

  return <div>Protected content</div>;
}
permanentRedirect() 函数
import { permanentRedirect } from 'next/navigation';

export default async function OldPage() {
  permanentRedirect('/new-page'); // 308 status
}

最佳实践

实践说明
使用路由组进行组织按功能或布局分组
实现加载状态为每个段添加 loading.tsx
在仪表盘中使用并行路由独立的加载/错误状态
使用拦截路由实现模态框为叠加层提供更好的 UX
整理私有文件夹使用 _ 前缀表示非路由文件夹
为参数添加类型params 和 searchParams 使用 Promise<>

评论

支持 Markdown