# Nextjs Idioms

> Next.js App Router, RSC, Server Actions, ISR. For React see react-idioms. For TypeScript see typescript-idioms.

- Skill: `irahardianto/nextjs-idioms-2` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add irahardianto/nextjs-idioms-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/irahardianto/nextjs-idioms-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: irahardianto (https://skillmd.com/u/irahardianto)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/irahardianto/nextjs-idioms-2

---


## 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 <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>;
   }
   ```
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 (
       <>
         <TaskStats count={tasks.length} />   {/* Server */}
         <TaskFilterBar />                     {/* 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 <TaskList tasks={tasks} />;
   }
   ```
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 <div><h2>Something went wrong</h2><button onClick={reset}>Retry</button></div>;
   }
   ```
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 <TaskDetail task={task} />;
   }
   ```
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<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

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
   <Suspense fallback={<TasksSkeleton />}>
     <TaskList />  {/* Server Component — streams when ready */}
   </Suspense>
   ```
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<Metadata> {
     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

