关于 nextjs-app-router
nextjs-app-router 是面向 Next.js App Router 开发者的参考技能,介绍文件路由、布局、加载与错误界面、导航和元数据约定。
主要功能
- 01App Router 文件约定
- 02动态路由与路由分组
- 03持久布局与模板
- 04加载状态与错误边界
- 05静态和动态元数据
适用场景
- 01规划新的 Next.js App Router 项目结构
- 02添加动态路由或路由分组
- 03构建共享布局和加载界面
- 04处理路由错误与页面元数据
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ josiahsiegel/ claude-plugin-marketplace --skill nextjs-app-router安装后在 Claude Code 中输入/nextjs-app-router确认已加载。
详细介绍
快速参考
| 约定 | 文件 | 用途 |
|---|---|---|
| 页面 | page.tsx | 路由 UI(路由必需) |
| 布局 | layout.tsx | 共享 UI(在导航间保持不变) |
| 加载 | loading.tsx | 使用 Suspense 的加载 UI |
| 错误 | error.tsx | 错误边界(必须使用 'use client') |
| 未找到 | not-found.tsx | 404 UI |
| 模板 | template.tsx | 导航时重新渲染 |
| 模式 | 语法 | 示例 |
|---|---|---|
| 动态路由 | [slug] | /blog/[slug]/page.tsx |
| 捕获所有路由 | [...slug] | /docs/[...slug]/page.tsx |
| 可选捕获所有路由 | [[...slug]] | /shop/[[...slug]]/page.tsx |
| 路由组 | (name) | /(marketing)/about/page.tsx |
| 并行路由 | @slot | /@analytics/page.tsx |
何时使用此技能
适用于 App Router 基础知识:
- 使用 App Router 搭建新的 Next.js 15 项目
- 了解基于文件的路由约定
- 创建可在导航间保持不变的布局
- 实现加载状态和错误边界
- 使用路由组组织路由
相关技能:
- 数据获取:参见
nextjs-data-fetching - 高级路由:参见
nextjs-routing-advanced - 缓存策略:参见
nextjs-caching
Next.js App Router 基础知识
项目结构
App 目录
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── global-error.tsx # Global error boundary
├── template.tsx # Re-renders on navigation
│
├── (marketing)/ # Route group (no URL impact)
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
│
├── dashboard/
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # /dashboard
│ ├── loading.tsx # Dashboard loading
│ ├── @analytics/ # Parallel route
│ │ └── page.tsx
│ ├── @team/ # Parallel route
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx # /dashboard/settings
│
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/ # Dynamic route
│ ├── page.tsx # /blog/:slug
│ └── opengraph-image.tsx
│
├── products/
│ └── [...slug]/ # Catch-all route
│ └── page.tsx # /products/*
│
├── shop/
│ └── [[...slug]]/ # Optional catch-all
│ └── page.tsx # /shop or /shop/*
│
└── api/
└── users/
└── route.ts # API route handler
布局
根布局(必需)
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
template: '%s | My App',
default: 'My App',
},
description: 'My application description',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main>{children}</main>
<footer>{/* Footer */}</footer>
</body>
</html>
);
}
嵌套布局
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard">
<Sidebar />
<div className="dashboard-content">{children}</div>
</div>
);
}
带并行路由的布局
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="dashboard">
<div className="main">{children}</div>
<div className="sidebar">
{analytics}
{team}
</div>
</div>
);
}
页面
基本页面
// app/page.tsx
export default function HomePage() {
return (
<div>
<h1>Welcome to My App</h1>
<p>This is the home page.</p>
</div>
);
}
异步服务端组件页面
// app/posts/page.tsx
import { db } from '@/lib/db';
async function getPosts() {
return db.posts.findMany({
orderBy: { createdAt: 'desc' },
});
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
动态路由页面
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = await db.posts.findUnique({ where: { slug } });
if (!post) {
return { title: 'Post Not Found' };
}
return {
title: post.title,
description: post.excerpt,
};
}
export async function generateStaticParams() {
const posts = await db.posts.findMany({ select: { slug: true } });
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await db.posts.findUnique({ where: { slug } });
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
加载 UI
加载组件
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-container">
<div className="spinner" />
<p>Loading dashboard...</p>
</div>
);
}
骨架屏加载
// app/posts/loading.tsx
export default function PostsLoading() {
return (
<div className="posts-skeleton">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="post-skeleton">
<div className="skeleton-title" />
<div className="skeleton-excerpt" />
</div>
))}
</div>
);
}
使用 Suspense 流式传输
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Analytics } from './analytics';
import { RecentSales } from './recent-sales';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
<Suspense fallback={<SalesSkeleton />}>
<RecentSales />
</Suspense>
</div>
);
}
错误处理
错误边界
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
全局错误边界
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}
未找到页面
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="not-found">
<h2>Page Not Found</h2>
<p>Could not find the requested resource.</p>
<Link href="/">Return Home</Link>
</div>
);
}
触发未找到状态
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return <article>{/* ... */}</article>;
}
元数据
静态元数据
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn more about our company',
openGraph: {
title: 'About Us',
description: 'Learn more about our company',
images: ['/og-about.jpg'],
},
};
export default function AboutPage() {
return <div>About content</div>;
}
动态元数据
// app/products/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
const previousImages = (await parent).openGraph?.images || [];
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image, ...previousImages],
},
};
}
元数据模板
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | My Store',
default: 'My Store',
},
metadataBase: new URL('https://mystore.com'),
};
// app/products/page.tsx
export const metadata: Metadata = {
title: 'Products', // Results in "Products | My Store"
};
模板与布局
// app/dashboard/template.tsx
// Template re-mounts on navigation (state resets)
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<nav>Dashboard Navigation</nav>
{children}
</div>
);
}
路由组
// (marketing)/about/page.tsx → /about
// (marketing)/contact/page.tsx → /contact
// (shop)/products/page.tsx → /products
// (shop)/cart/page.tsx → /cart
// Different layouts for different sections
// app/(marketing)/layout.tsx - Marketing layout
// app/(shop)/layout.tsx - Shop layout
客户端组件
// components/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
组合服务端组件和客户端组件
// app/page.tsx (Server Component)
import { Counter } from '@/components/Counter';
import { db } from '@/lib/db';
export default async function Page() {
const initialData = await db.getData();
return (
<div>
<h1>Server rendered title</h1>
<Counter />
<ClientDataDisplay data={initialData} />
</div>
);
}
导航
Link 组件
import Link from 'next/link';
export function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog" prefetch={false}>Blog</Link>
<Link href="/dashboard" replace>Dashboard</Link>
</nav>
);
}
useRouter Hook
'use client';
import { useRouter } from 'next/navigation';
export function NavigateButton() {
const router = useRouter();
return (
<button onClick={() => router.push('/dashboard')}>
Go to Dashboard
</button>
);
}
usePathname 和 useSearchParams
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
export function CurrentPath() {
const pathname = usePathname();
const searchParams = useSearchParams();
const query = searchParams.get('q');
return (
<div>
<p>Current path: {pathname}</p>
{query && <p>Search query: {query}</p>}
</div>
);
}
最佳实践
| 实践 | 描述 |
|---|---|
| 默认使用服务端组件 | 仅在需要时使用 'use client' |
| 将相关文件放在一起 | 将页面、加载和错误文件放在一起 |
| 使用路由组 | 在不影响 URL 的情况下组织路由 |
| 实现加载状态 | 使用 loading.tsx 或 Suspense |
| 妥善处理错误 | 使用 error.tsx 边界 |
| 优化元数据 | 动态页面使用 generateMetadata |
评论
支持 Markdown