nextjs-idioms
SkillDev toolsNext.js App Router, RSC, Server Actions, ISR. For React see react-idioms. For TypeScript see typescript-idioms.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the nextjs-idioms skill
What this skill tells your AI
The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/nextjs-idioms/SKILL.md and read by ahel’s review.
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 onClick={reset}>Retry</button></div>; }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
Signals
- GitHub stars
- 156
- Forks
- 53
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
nextjs-idioms- Source
- github.com/irahardianto/awesome-agv