commit a70b699df599c375ffb28a3a0c4392c8579bc44f Author: zlei9 Date: Sun Mar 29 10:17:47 2026 +0800 Initial commit with translated description diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..c8d878b --- /dev/null +++ b/SKILL.md @@ -0,0 +1,766 @@ +--- +name: nextjs-expert +version: 1.0.0 +description: "使用App Router构建Next.js 14/15应用程序时使用。用于路由、布局、服务器组件、客户端组件、服务器操作、路由处理程序、认证、中间件、数据获取、缓存等。" +triggers: + - Next.js + - Next + - nextjs + - App Router + - Server Components + - Client Components + - Server Actions + - use server + - use client + - Route Handler + - middleware + - layout.tsx + - page.tsx + - loading.tsx + - error.tsx + - revalidatePath + - revalidateTag + - NextAuth + - Auth.js + - generateStaticParams + - generateMetadata +role: specialist +scope: implementation +output-format: code +--- + +# Next.js Expert + +Comprehensive Next.js 15 App Router specialist. Adapted from buildwithclaude by Dave Poon (MIT). + +## Role Definition + +You are a senior Next.js engineer specializing in the App Router, React Server Components, and production-grade full-stack applications with TypeScript. + +## Core Principles + +1. **Server-first**: Components are Server Components by default. Only add `'use client'` when you need hooks, event handlers, or browser APIs. +2. **Push client boundaries down**: Keep `'use client'` as low in the tree as possible. +3. **Async params**: In Next.js 15, `params` and `searchParams` are `Promise` types — always `await` them. +4. **Colocation**: Keep components, tests, and styles near their routes. +5. **Type everything**: Use TypeScript strictly. + +--- + +## App Router File Conventions + +### Route Files + +| File | Purpose | +|------|---------| +| `page.tsx` | Unique UI for a route, makes it publicly accessible | +| `layout.tsx` | Shared UI wrapper, preserves state across navigations | +| `loading.tsx` | Loading UI using React Suspense | +| `error.tsx` | Error boundary for route segment (must be `'use client'`) | +| `not-found.tsx` | UI for 404 responses | +| `template.tsx` | Like layout but re-renders on navigation | +| `default.tsx` | Fallback for parallel routes | +| `route.ts` | API endpoint (Route Handler) | + +### Folder Conventions + +| Pattern | Purpose | Example | +|---------|---------|---------| +| `folder/` | Route segment | `app/blog/` → `/blog` | +| `[folder]/` | Dynamic segment | `app/blog/[slug]/` → `/blog/:slug` | +| `[...folder]/` | Catch-all segment | `app/docs/[...slug]/` → `/docs/*` | +| `[[...folder]]/` | Optional catch-all | `app/shop/[[...slug]]/` → `/shop` or `/shop/*` | +| `(folder)/` | Route group (no URL) | `app/(marketing)/about/` → `/about` | +| `@folder/` | Named slot (parallel routes) | `app/@modal/login/` | +| `_folder/` | Private folder (excluded) | `app/_components/` | + +### File Hierarchy (render order) + +1. `layout.tsx` → 2. `template.tsx` → 3. `error.tsx` (boundary) → 4. `loading.tsx` (boundary) → 5. `not-found.tsx` (boundary) → 6. `page.tsx` + +--- + +## Pages and Routing + +### Basic Page (Server Component) + +```tsx +// app/about/page.tsx +export default function AboutPage() { + return ( +
+

About Us

+

Welcome to our company.

+
+ ) +} +``` + +### Dynamic Routes + +```tsx +// 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
{post.content}
+} +``` + +### Search Params + +```tsx +// app/search/page.tsx +interface PageProps { + searchParams: Promise<{ q?: string; page?: string }> +} + +export default async function SearchPage({ searchParams }: PageProps) { + const { q, page } = await searchParams + const results = await search(q, parseInt(page || '1')) + return +} +``` + +### Static Generation + +```tsx +export async function generateStaticParams() { + const posts = await getAllPosts() + return posts.map((post) => ({ slug: post.slug })) +} + +// Allow dynamic params not in generateStaticParams +export const dynamicParams = true +``` + +--- + +## Layouts + +### Root Layout (Required) + +```tsx +// app/layout.tsx +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} +``` + +### Nested Layout with Data Fetching + +```tsx +// app/dashboard/layout.tsx +import { getUser } from '@/lib/get-user' + +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { + const user = await getUser() + return ( +
+ +
{children}
+
+ ) +} +``` + +### Route Groups for Multiple Root Layouts + +``` +app/ +├── (marketing)/ +│ ├── layout.tsx # Marketing layout with / +│ └── about/page.tsx +└── (app)/ + ├── layout.tsx # App layout with / + └── dashboard/page.tsx +``` + +### Metadata + +```tsx +// Static +export const metadata: Metadata = { + title: 'About Us', + description: 'Learn more about our company', +} + +// Dynamic +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params + const post = await getPost(slug) + return { + title: post.title, + openGraph: { title: post.title, images: [post.coverImage] }, + } +} + +// Template in layouts +export const metadata: Metadata = { + title: { template: '%s | Dashboard', default: 'Dashboard' }, +} +``` + +--- + +## Server Components vs Client Components + +### Decision Guide + +**Server Component (default) when:** +- Fetching data or accessing backend resources +- Keeping sensitive info on server (API keys, tokens) +- Reducing client JavaScript bundle +- No interactivity needed + +**Client Component (`'use client'`) when:** +- Using `useState`, `useEffect`, `useReducer` +- Using event handlers (`onClick`, `onChange`) +- Using browser APIs (`window`, `document`) +- Using custom hooks with state + +### Composition Patterns + +**Pattern 1: Server data → Client interactivity** + +```tsx +// app/products/page.tsx (Server) +export default async function ProductsPage() { + const products = await getProducts() + return +} + +// components/product-filter.tsx (Client) +'use client' +export function ProductFilter({ products }: { products: Product[] }) { + const [filter, setFilter] = useState('') + const filtered = products.filter(p => p.name.includes(filter)) + return ( + <> + setFilter(e.target.value)} /> + {filtered.map(p => )} + + ) +} +``` + +**Pattern 2: Children as Server Components** + +```tsx +// components/client-wrapper.tsx +'use client' +export function ClientWrapper({ children }: { children: React.ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + return ( +
+ + {isOpen && children} +
+ ) +} + +// app/page.tsx (Server) +export default function Page() { + return ( + + {/* Still renders on server! */} + + ) +} +``` + +**Pattern 3: Providers at the boundary** + +```tsx +// app/providers.tsx +'use client' +import { ThemeProvider } from 'next-themes' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient() + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} +``` + +### Shared Data with `cache()` + +```tsx +import { cache } from 'react' + +export const getUser = cache(async () => { + const response = await fetch('/api/user') + return response.json() +}) + +// Both layout and page call getUser() — only one fetch happens +``` + +--- + +## Data Fetching + +### Async Server Components + +```tsx +export default async function PostsPage() { + const posts = await fetch('https://api.example.com/posts').then(r => r.json()) + return
    {posts.map(p =>
  • {p.title}
  • )}
+} +``` + +### Parallel Data Fetching + +```tsx +export default async function DashboardPage() { + const [user, posts, analytics] = await Promise.all([ + getUser(), getPosts(), getAnalytics() + ]) + return +} +``` + +### Streaming with Suspense + +```tsx +import { Suspense } from 'react' + +export default function DashboardPage() { + return ( +
+

Dashboard

+ }> + + + }> + + +
+ ) +} +``` + +### Caching + +```tsx +// Cache indefinitely (static) +const data = await fetch('https://api.example.com/data') + +// Revalidate every hour +const data = await fetch(url, { next: { revalidate: 3600 } }) + +// No caching (always fresh) +const data = await fetch(url, { cache: 'no-store' }) + +// Cache with tags +const data = await fetch(url, { next: { tags: ['posts'] } }) +``` + +--- + +## Loading and Error States + +### Loading UI + +```tsx +// app/dashboard/loading.tsx +export default function Loading() { + return ( +
+
+
+
+
+
+
+ ) +} +``` + +### Error Boundary + +```tsx +// app/dashboard/error.tsx +'use client' + +export default function Error({ error, reset }: { error: Error; reset: () => void }) { + return ( +
+

Something went wrong!

+

{error.message}

+ +
+ ) +} +``` + +### Not Found + +```tsx +// 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
{post.content}
+} +``` + +--- + +## Server Actions + +### Defining Actions + +```tsx +// app/actions.ts +'use server' + +import { z } from 'zod' +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' + +const schema = z.object({ + title: z.string().min(1).max(200), + content: z.string().min(10), +}) + +export async function createPost(formData: FormData) { + const session = await auth() + if (!session?.user) throw new Error('Unauthorized') + + const parsed = schema.safeParse({ + title: formData.get('title'), + content: formData.get('content'), + }) + + if (!parsed.success) return { error: parsed.error.flatten() } + + const post = await db.post.create({ + data: { ...parsed.data, authorId: session.user.id }, + }) + + revalidatePath('/posts') + redirect(`/posts/${post.slug}`) +} +``` + +### Form with useFormState and useFormStatus + +```tsx +// components/submit-button.tsx +'use client' +import { useFormStatus } from 'react-dom' + +export function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +// components/create-post-form.tsx +'use client' +import { useFormState } from 'react-dom' +import { createPost } from '@/app/actions' + +export function CreatePostForm() { + const [state, formAction] = useFormState(createPost, {}) + return ( +
+ + {state.error?.title &&

{state.error.title[0]}

} +