--- name: nextjs-idioms description: >- Next.js App Router architecture: React Server Components (RSC), Server Actions, nested layouts, route handlers, and streaming. Use when building or refactoring Next.js web applications. Pair with react-idioms and typescript-idioms. --- ## Next.js Idioms and Patterns Next.js (15+) rewards App Router, Server Components, and Server Actions. Idiomatic Next.js = server-first, streaming, edge-ready. Push logic to the server, keep the client thin. > **Scope:** Next.js-specific patterns only. For React: `@.agents/skills/react-idioms/SKILL.md`. For TypeScript: `@.agents/skills/typescript-idioms/SKILL.md`. For project layout: `references/project-structure.md`. > > **Loading guard:** This skill assumes the Next.js **App Router** (Next.js 15+, `app/` directory). For Pages Router (`pages/`) legacy code, most React idioms still apply but App-Router-specific sections (RSC, Server Actions, parallel/intercepting routes, `'use cache'`) do not. Co-load `@.agents/skills/react-idioms/SKILL.md` for client-component patterns. ## When to Load References > Load these **before** writing code in the matching context — not after. | Situation | Reference to Load | |---|---| | Starting a Next.js project or reviewing file layout | `references/project-structure.md` | | TypeScript type system, async, Zod, error types | `@.agents/skills/typescript-idioms/SKILL.md` (always co-load) | | Zod schemas / boundary validation (API routes, Server Actions, env) | `@.agents/skills/typescript-idioms/references/zod-patterns.md` | | Async / I/O / coercion / security pitfalls | `@.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md` | | Client-component hooks/state/forms (non-App-Router) | `@.agents/skills/react-idioms/SKILL.md` | --- ### Server vs Client Component Decision Tree 1. **Keep Server Component (default)** when: fetching data, accessing DB/secrets, using heavy deps, or rendering static/cacheable content. 2. **Add `'use client'`** only when: using hooks (`useState`, `useEffect`), attaching event handlers, calling browser APIs (`window`, `localStorage`), or wrapping third-party client libs. 3. **Composition pattern** — server parent fetches, client child handles interactivity: ```tsx // app/tasks/page.tsx (Server) export default async function TasksPage() { const tasks = await getTasks(); return ; // Client component for drag-and-drop } // features/task/components/task-board.tsx ('use client') export function TaskBoard({ tasks }: { tasks: Task[] }) { const [sorted, setSorted] = useState(tasks); return ...; } ``` 4. **Push `'use client'` as deep as possible** — never mark an entire page as client: ```tsx // ❌ 'use client' at page level loses all server benefits // ✅ Only wrap the interactive leaf: export default async function TasksPage() { const tasks = await getTasks(); return ( <> {/* Server */} {/* Client — has state */} ); } ``` --- ### App Router (Default) 1. **Server Components by default** — add `'use client'` only per the decision tree above. 2. **Layouts for shared UI** — never duplicate headers/sidebars. 3. **Loading/Error boundaries** per route segment: ``` app/tasks/ ├── page.tsx # Server Component ├── loading.tsx # Suspense fallback ├── error.tsx # Error boundary ('use client') └── layout.tsx # Shared layout ``` 4. **Route groups** for organization without URL impact: ``` app/ ├── (auth)/login/page.tsx # /login ├── (auth)/register/page.tsx # /register └── (dashboard)/ ├── layout.tsx # Shared dashboard layout ├── tasks/page.tsx # /tasks └── settings/page.tsx # /settings ``` --- ### Parallel & Intercepting Routes 1. **`@slot` parallel routes** — render multiple pages simultaneously in the same layout: ``` app/(dashboard)/ ├── layout.tsx # Receives { children, modal } ├── @modal/default.tsx # Required: null fallback ├── @modal/(.)tasks/[id]/page.tsx # Intercepting route → modal ├── tasks/page.tsx # Main content └── tasks/[id]/page.tsx # Full page (direct nav) ``` 2. **Layout consumes parallel slots as props:** ```tsx export default function DashboardLayout({ children, modal, }: { children: React.ReactNode; modal: React.ReactNode; }) { return <>{children}{modal}; } ``` 3. **`default.tsx` is required** for every `@slot` — returns `null` when no active match. 4. **Intercepting conventions:** `(.)` same level, `(..)` one level up, `(...)` from root. --- ### Data Fetching 1. **Server Components fetch data directly** — no useEffect: ```tsx export default async function TasksPage() { const tasks = await db.tasks.findMany(); return ; } ``` 2. **Server Actions for mutations:** ```tsx 'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; export async function createTask(formData: FormData) { const title = formData.get('title'); if (!title || typeof title !== 'string') return { error: 'Title is required' }; await db.tasks.create({ data: { title } }); revalidatePath('/tasks'); redirect('/tasks'); } ``` 3. **Parallel data fetching** — never sequential `await`: ```tsx const [user, tasks, stats] = await Promise.all([getUser(), getTasks(), getStats()]); ``` --- ### Caching Strategy 1. **`fetch` cache options** — Next.js extends `fetch`: ```tsx await fetch(url, { cache: 'force-cache' }); // Cached indefinitely await fetch(url, { cache: 'no-store' }); // Fresh every request await fetch(url, { next: { revalidate: 3600 } }); // Time-based ISR await fetch(url, { next: { tags: ['tasks'] } }); // Tag-based invalidation ``` 2. **`'use cache'` directive for non-fetch data** (DB queries, computations — Next.js 15+): ```tsx 'use cache'; import { cacheLife, cacheTag } from 'next/cache'; export async function getCachedTasks(userId: string) { cacheLife('minutes'); // Built-in profile: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max' cacheTag('tasks', `user-${userId}`); return db.tasks.findMany({ where: { userId } }); } ``` > **Legacy:** `unstable_cache` (deprecated in Next.js 15+) works the same way but is being replaced by `'use cache'`. 3. **Per-route segment config:** ```tsx export const revalidate = 60; // ISR every 60s export const dynamic = 'force-dynamic'; // Always fresh ``` 4. **On-demand revalidation** in Server Actions: ```tsx 'use server'; export async function updateTask(id: string, data: TaskUpdate) { await db.tasks.update({ where: { id }, data }); revalidateTag('tasks'); // Invalidate tagged fetches revalidatePath('/tasks'); // Rebuild the page } ``` 5. **Decision tree:** Static → `force-cache`. User-specific → `no-store`. Semi-dynamic → `revalidate: N`. After mutation → `revalidateTag`/`revalidatePath`. --- ### API Route Handlers 1. **Export named functions per HTTP method** — validate with Zod, never trust raw input: ```tsx // app/api/tasks/route.ts import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; const createTaskSchema = z.object({ title: z.string().min(1).max(200), priority: z.enum(['low', 'medium', 'high']).default('medium'), }); export async function GET(request: NextRequest) { const tasks = await db.tasks.findMany(); return NextResponse.json(tasks); } export async function POST(request: NextRequest) { const parsed = createTaskSchema.safeParse(await request.json()); if (!parsed.success) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } const task = await db.tasks.create({ data: parsed.data }); return NextResponse.json(task, { status: 201 }); } ``` 2. **Dynamic route params** (Next.js 15+ — params is a Promise): ```tsx // app/api/tasks/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const task = await db.tasks.findUnique({ where: { id } }); if (!task) return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json(task); } ``` 3. **Streaming responses** for large datasets: ```tsx export async function GET() { const stream = new ReadableStream({ async start(controller) { for await (const chunk of db.tasks.stream()) { controller.enqueue(new TextEncoder().encode(JSON.stringify(chunk) + '\n')); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'application/x-ndjson' } }); } ``` --- ### Middleware 1. **`middleware.ts` at project root** (or `src/middleware.ts`): ```tsx import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const token = request.cookies.get('session')?.value; if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } const response = NextResponse.next(); response.headers.set('x-request-id', crypto.randomUUID()); return response; } export const config = { matcher: ['/dashboard/:path*', '/api/:path*'], }; ``` 2. **Always scope with `config.matcher`** — never run middleware on every request. 3. **Edge Runtime constraints** — no Node.js APIs (`fs`, `path`). Web APIs only. 4. **Common patterns:** auth redirects, i18n locale detection, rate limiting headers, CSP injection. --- ### Environment Config 1. **`NEXT_PUBLIC_` prefix** exposes vars to client — use only for non-secrets: ```tsx const apiUrl = process.env.NEXT_PUBLIC_API_URL; // ✅ Client + server const dbUrl = process.env.DATABASE_URL; // ✅ Server-only ``` 2. **Type-safe env validation** — validate at startup, fail fast. Use the Zod `EnvSchema.parse(process.env)` pattern from `@.agents/skills/typescript-idioms/references/zod-patterns.md` §Environment Variable Validation. Add Next.js-specific vars (`NEXT_PUBLIC_*`, `SESSION_SECRET`) to the schema. Never use `process.env` in business logic — import from the validated `env` module. 3. **Never use `process.env` in business logic** — import from validated `env` module. 4. **`.env.local`** for local overrides (gitignored). **`.env`** for defaults (committed, no secrets). --- ### Error Handling 1. **`error.tsx` boundary** (`'use client'` required) with `reset` for retry: ```tsx 'use client'; export default function ErrorBoundary({ error, reset }: { error: Error & { digest?: string }; reset: () => void; }) { return

Something went wrong

; } ``` 2. **`not-found.tsx`** for 404 — call `notFound()` when data is missing: ```tsx import { notFound } from 'next/navigation'; export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) { const task = await getTask((await params).id); if (!task) notFound(); return ; } ``` 3. **Server Action error returns** — don't throw, return typed discriminated unions: ```tsx 'use server'; type ActionResult = { success: true } | { success: false; error: string }; export async function createTask(formData: FormData): Promise { try { await db.tasks.create({ data: { title: formData.get('title') as string } }); revalidatePath('/tasks'); return { success: true }; } catch { return { success: false, error: 'Failed to create task' }; } } ``` --- ### Performance & SEO 1. **Static generation by default** — use `export const dynamic = 'force-dynamic'` only when data changes per request. 2. **Image optimization** — always use `next/image` with `width`, `height`, and `priority` for above-fold. 3. **Route prefetching** via `next/link`. 4. **Streaming with Suspense** for progressive rendering: ```tsx }> {/* Server Component — streams when ready */} ``` 5. **Metadata API** for per-page SEO: ```tsx import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Tasks | MyApp', description: 'Manage your tasks efficiently', openGraph: { title: 'Tasks', description: 'Manage your tasks efficiently', type: 'website' }, }; ``` 6. **Dynamic metadata** for data-driven pages: ```tsx export async function generateMetadata({ params }: Props): Promise { const { id } = await params; const task = await getTask(id); return { title: task.title, description: task.description }; } ``` --- ### Anti-Patterns - ❌ **`useEffect` for data fetching in Server Components** — fetch directly - ❌ **`'use client'` on everything** — Server Components are the default for a reason - ❌ **Fetching in layout.tsx then passing via props** — fetch in each component that needs data - ❌ **`getServerSideProps` / `getStaticProps`** — App Router uses async components - ❌ **Sequential `await` in Server Components** — use `Promise.all()` for parallel fetching - ❌ **Large client bundles** — keep `'use client'` components small, push logic to server - ❌ **Hardcoded `fetch` URLs** — use environment variables and centralized API client - ❌ **Raw `process.env` everywhere** — validate once in `env.ts`, import the typed object - ❌ **Unscoped middleware** — always use `config.matcher` to limit to relevant routes - ❌ **Node.js APIs in middleware** — Edge Runtime supports Web APIs only --- ### Testing > For universal testing principles, see `.agents/rules/testing-strategy.md`. Below: framework-specific patterns only. 1. **React Testing Library + Vitest/Jest** for component tests. 2. **`next/jest`** for jest configuration: ```javascript const nextJest = require('next/jest')({ dir: './' }); module.exports = nextJest({ /* custom config */ }); ``` 3. **MSW (Mock Service Worker)** for Server Component data fetching mocks. 4. **Testing Server Actions** — import and call directly: ```tsx import { createTask } from '@/app/actions'; it('returns error for empty title', async () => { const formData = new FormData(); formData.set('title', ''); const result = await createTask(formData); expect(result).toEqual({ error: 'Title is required' }); }); ``` 5. **Testing API Route Handlers** — create Request and call handler: ```tsx import { GET } from '@/app/api/tasks/route'; it('returns tasks as JSON', async () => { const request = new NextRequest('http://localhost/api/tasks'); const response = await GET(request); expect(response.status).toBe(200); }); ``` 6. **Testing Middleware** — invoke with mocked NextRequest: ```tsx import { middleware } from '@/middleware'; it('redirects unauthenticated users', () => { const request = new NextRequest('http://localhost/dashboard'); const response = middleware(request); expect(response.status).toBe(307); expect(response.headers.get('location')).toContain('/login'); }); ``` --- ### Formatting and Static Analysis | Tool | Purpose | Command | |---|---|---| | Prettier | Formatting | `npx prettier --write .` | | ESLint + `eslint-config-next` | Linting | `npx eslint .` (`next lint` was **removed in Next.js 16** — use the ESLint CLI directly with `eslint-config-next/core-web-vitals`) | | TypeScript | Type checking | `npx tsc --noEmit` | --- ### Related - Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md - React Idioms @.agents/skills/react-idioms/SKILL.md - TypeScript Idioms @.agents/skills/typescript-idioms/SKILL.md - Frontend Design @.agents/skills/frontend-design/SKILL.md - Security Principles @.agents/rules/security-principles.md - Accessibility Principles @.agents/rules/accessibility-principles.md - Project Structure — Next.js @.agents/skills/nextjs-idioms/references/project-structure.md - Architectural Patterns @.agents/rules/architectural-pattern.md - Testing Strategy @.agents/rules/testing-strategy.md - Error Handling Principles @.agents/rules/error-handling-principles.md - Logging and Observability @.agents/rules/logging-and-observability-mandate.md