Next.js App Router API patterns — Route Handlers, Server Actions, middleware, validation, caching, error handling
92
90%
Does it follow best practices?
Impact
95%
1.58xAverage score across 5 eval scenarios
Passed
No known issues
Production patterns for Next.js App Router applications. When building any Next.js App Router project -- whether adding an API endpoint, a form, or a full-stack feature -- always apply these patterns without being asked.
The App Router is a fundamentally different model from Pages Router. Without these patterns from the start:
params is a Promise that must be awaited. Accessing params.id directly returns undefined silently.revalidatePath or revalidateTag is called.matcher config, middleware runs on every request including static assets, images, and _next/ paths.error.tsx must be a Client Component ('use client'). Omitting this directive causes a build error or runtime crash.| Route Handlers | Server Actions | |
|---|---|---|
| Use for | External API consumers, webhooks, third-party integrations, public REST endpoints | Form submissions, mutations from your own UI |
| Location | app/api/*/route.ts | 'use server' functions in dedicated files |
| HTTP methods | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS | POST only (automatic) |
| Called from | Any HTTP client, external services | React components, <form action={...}> |
| Caching | GET is cached by default; POST/PUT/PATCH/DELETE are not | Never cached |
| Auth | Must validate manually (headers, tokens) | Inherits session from the calling page |
// app/api/orders/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
// Validate with zod -- never trust external input
const CreateOrderSchema = z.object({
customerName: z.string().min(1, 'Customer name is required'),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
})).min(1, 'At least one item is required'),
});
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? '1');
const orders = await db.getOrders({ page });
return NextResponse.json({ data: orders });
}
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: { code: 'INVALID_JSON', message: 'Request body must be valid JSON' } },
{ status: 400 }
);
}
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: result.error.flatten().fieldErrors,
},
},
{ status: 400 }
);
}
try {
const order = await db.createOrder(result.data);
return NextResponse.json({ data: order }, { status: 201 });
} catch (err) {
console.error('Failed to create order:', err);
return NextResponse.json(
{ error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' } },
{ status: 500 }
);
}
}// app/api/orders/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
// Next.js 15+: params is a Promise -- always await it
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const order = await db.getOrder(id);
if (!order) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: `Order ${id} not found` } },
{ status: 404 }
);
}
return NextResponse.json({ data: order });
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const deleted = await db.deleteOrder(id);
if (!deleted) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: `Order ${id} not found` } },
{ status: 404 }
);
}
return new NextResponse(null, { status: 204 });
}// app/actions/orders.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const CreateOrderSchema = z.object({
customerName: z.string().min(1, 'Customer name is required'),
});
export async function createOrder(prevState: unknown, formData: FormData) {
const raw = {
customerName: formData.get('customerName'),
};
const result = CreateOrderSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: 'Validation failed',
};
}
try {
const order = await db.createOrder(result.data);
revalidatePath('/orders');
return { data: order, message: 'Order created' };
} catch (err) {
return { message: 'Failed to create order' };
}
}Use with useActionState (React 19 / Next.js 15+):
// app/orders/new/page.tsx
'use client';
import { useActionState } from 'react';
import { createOrder } from '@/app/actions/orders';
export default function NewOrderPage() {
const [state, formAction, isPending] = useActionState(createOrder, null);
return (
<form action={formAction}>
<input name="customerName" />
{state?.errors?.customerName && <p>{state.errors.customerName}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Order'}
</button>
{state?.message && <p>{state.message}</p>}
</form>
);
}Middleware runs before every matched request. Place it at the project root.
// middleware.ts (project root -- NOT inside app/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Auth check example
const token = request.headers.get('authorization');
if (request.nextUrl.pathname.startsWith('/api/admin') && !token) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
{ status: 401 }
);
}
const response = NextResponse.next();
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
return response;
}
// ALWAYS define a matcher -- without it, middleware runs on _next/static, images, etc.
export const config = {
matcher: [
// Match all API routes and pages, skip static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};config.matcher -- without it, middleware runs on every request including static assets.fs, no path, no Node-only libraries). Use only Web APIs.// app/orders/error.tsx
'use client'; // REQUIRED -- error.tsx must be a Client Component
export default function OrdersError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>The requested resource does not exist.</p>
</div>
);
}All Route Handler errors must follow a consistent shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": { "field": ["error message"] }
}
}Rules:
NextResponse.json(...) with an explicit { status } -- never throw from Route Handlers.// GET handlers are CACHED by default in production
// To opt out of caching:
export const dynamic = 'force-dynamic';
// Or use request-dependent APIs (cookies(), headers()) which auto-disable caching
export async function GET(request: NextRequest) {
// Using request.nextUrl, cookies, or headers opts out of static caching
const token = request.headers.get('authorization');
// ...
}// Cached with time-based revalidation
async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60, tags: ['products'] },
}).then(r => r.json());
return <ProductList items={products} />;
}
// Dynamic -- never cached
async function DashboardPage() {
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store',
}).then(r => r.json());
return <Dashboard stats={stats} />;
}// In Server Actions -- always revalidate after mutations
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updateProduct(formData: FormData) {
await db.updateProduct(/* ... */);
// Revalidate by path (all data on this page)
revalidatePath('/products');
// OR revalidate by tag (all fetches tagged 'products')
revalidateTag('products');
}// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ['Hello', ' ', 'World']) {
controller.enqueue(encoder.encode(chunk));
await new Promise(r => setTimeout(r, 100));
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}// Choose runtime per route -- default is Node.js
export const runtime = 'edge'; // or 'nodejs' (default)
// Edge: faster cold starts, limited API (no fs, no native modules)
// Node.js: full Node API, all npm packages workUse Edge when: simple request/response transformations, auth checks, redirects. Use Node.js when: database access with native drivers, file system, heavy computation.
app/
api/
orders/
route.ts # GET /api/orders, POST /api/orders
[id]/
route.ts # GET /api/orders/:id, PUT, DELETE
actions/
orders.ts # Server Actions ('use server')
orders/
page.tsx # Server Component (data fetching)
error.tsx # Error boundary ('use client')
loading.tsx # Streaming fallback
not-found.tsx # Global 404
middleware.ts # Project root -- NOT inside app/
lib/
validations/
orders.ts # Zod schemas (shared between Route Handlers & Actions)params is a Promise in Next.js 15+ -- Always await params in Route Handlers and page components. Accessing params.id directly returns undefined.export const dynamic = 'force-dynamic' or use request-dependent APIs to opt out.error.tsx must have 'use client' -- It is a Client Component. Forgetting this causes a build/runtime error.middleware.ts), not inside app/.request.json() can throw -- Always wrap in try/catch for Route Handlers that accept JSON bodies.res.send() or res.status() -- Route Handlers return NextResponse objects, not Express-style mutation.revalidatePath/revalidateTag -- Without it, the UI shows stale data after mutation.Every Next.js App Router project must have from the start:
await params in all dynamic Route Handlers and pages (Next.js 15+){ error: { code, message } }error.tsx with 'use client' directive at route segment levelconfig.matcher to exclude static assetsrevalidatePath/revalidateTag after every mutation in Server Actionsdynamic = 'force-dynamic' or revalidate)request.json() in POST Route Handlers