Skip to content
Back to skills

Nextjs Idioms

ASecurity

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.

  • 157 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 5, 2026
developmentjavascripttypescriptrustjavareactnextjsnodetestingrefactoringgit

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 30, 2026

npx -y skills add irahardianto/antigravity-setup --skill nextjs-idioms --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Nextjs Idioms?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Nextjs Idioms
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/irahardianto-nextjs-idioms/badge)](https://www.skillsdirectory.com/skills/irahardianto-nextjs-idioms)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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 <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

Files in this skill

  • SKILL.md16.9 KB
  • references/project-structure.md5.8 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.