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.mdfor 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
- Keep Server Component (default) when: fetching data, accessing DB/secrets, using heavy deps, or rendering static/cacheable content.
- Add
'use client'only when: using hooks (useState,useEffect), attaching event handlers, calling browser APIs (window,localStorage), or wrapping third-party client libs. - Composition pattern — server parent fetches, client child handles interactivity:
// app/tasks/page.tsx (Server) export default async function TasksPage() { const tasks = await getTasks(); return <TaskBoard tasks={tasks} />; // 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 <DndContext>...</DndContext>; } - Push
'use client'as deep as possible — never mark an entire page as client:// ❌ 'use client' at page level loses all server benefits // ✅ Only wrap the interactive leaf: export default async function TasksPage() { const tasks = await getTasks(); return ( <> <TaskStats count={tasks.length} /> {/* Server */} <TaskFilterBar /> {/* Client — has state */} </> ); }
App Router (Default)
- Server Components by default — add
'use client'only per the decision tree above. - Layouts for shared UI — never duplicate headers/sidebars.
- 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 - 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
@slotparallel 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)- Layout consumes parallel slots as props:
export default function DashboardLayout({ children, modal, }: { children: React.ReactNode; modal: React.ReactNode; }) { return <>{children}{modal}</>; } default.tsxis required for every@slot— returnsnullwhen no active match.- Intercepting conventions:
(.)same level,(..)one level up,(...)from root.
Data Fetching
- Server Components fetch data directly — no useEffect:
export default async function TasksPage() { const tasks = await db.tasks.findMany(); return <TaskList tasks={tasks} />; } - Server Actions for mutations:
'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'); } - Parallel data fetching — never sequential
await:const [user, tasks, stats] = await Promise.all([getUser(), getTasks(), getStats()]);
Caching Strategy
fetchcache options — Next.js extendsfetch: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'use cache'directive for non-fetch data (DB queries, computations — Next.js 15+):'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'.- Per-route segment config:
export const revalidate = 60; // ISR every 60s export const dynamic = 'force-dynamic'; // Always fresh - On-demand revalidation in Server Actions:
'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 } - Decision tree: Static →
force-cache. User-specific →no-store. Semi-dynamic →revalidate: N. After mutation →revalidateTag/revalidatePath.
API Route Handlers
- Export named functions per HTTP method — validate with Zod, never trust raw input:
// 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 }); } - Dynamic route params (Next.js 15+ — params is a Promise):
// 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); } - Streaming responses for large datasets:
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
middleware.tsat project root (orsrc/middleware.ts):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*'], };- Always scope with
config.matcher— never run middleware on every request. - Edge Runtime constraints — no Node.js APIs (
fs,path). Web APIs only. - Common patterns: auth redirects, i18n locale detection, rate limiting headers, CSP injection.
Environment Config
NEXT_PUBLIC_prefix exposes vars to client — use only for non-secrets:const apiUrl = process.env.NEXT_PUBLIC_API_URL; // ✅ Client + server const dbUrl = process.env.DATABASE_URL; // ✅ Server-only- 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 useprocess.envin business logic — import from the validatedenvmodule. - Never use
process.envin business logic — import from validatedenvmodule. .env.localfor local overrides (gitignored)..envfor defaults (committed, no secrets).
Error Handling
error.tsxboundary ('use client'required) withresetfor retry:'use client'; export default function ErrorBoundary({ error, reset }: { error: Error & { digest?: string }; reset: () => void; }) { return <div><h2>Something went wrong</h2><button }not-found.tsxfor 404 — callnotFound()when data is missing: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 <TaskDetail task={task} />; }- Server Action error returns — don't throw, return typed discriminated unions:
'use server'; type ActionResult = { success: true } | { success: false; error: string }; export async function createTask(formData: FormData): Promise<ActionResult> { 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
- Static generation by default — use
export const dynamic = 'force-dynamic'only when data changes per request. - Image optimization — always use
next/imagewithwidth,height, andpriorityfor above-fold. - Route prefetching via
next/link. - Streaming with Suspense for progressive rendering:
<Suspense fallback={<TasksSkeleton />}> <TaskList /> {/* Server Component — streams when ready */} </Suspense> - Metadata API for per-page SEO:
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' }, }; - Dynamic metadata for data-driven pages:
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params; const task = await getTask(id); return { title: task.title, description: task.description }; }
Anti-Patterns
- ❌
useEffectfor 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
awaitin Server Components — usePromise.all()for parallel fetching - ❌ Large client bundles — keep
'use client'components small, push logic to server - ❌ Hardcoded
fetchURLs — use environment variables and centralized API client - ❌ Raw
process.enveverywhere — validate once inenv.ts, import the typed object - ❌ Unscoped middleware — always use
config.matcherto 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.
- React Testing Library + Vitest/Jest for component tests.
next/jestfor jest configuration:const nextJest = require('next/jest')({ dir: './' }); module.exports = nextJest({ /* custom config */ });- MSW (Mock Service Worker) for Server Component data fetching mocks.
- Testing Server Actions — import and call directly:
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' }); }); - Testing API Route Handlers — create Request and call handler:
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); }); - Testing Middleware — invoke with mocked NextRequest:
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