---
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