CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/nextjs-api-patterns

Next.js App Router API patterns — Route Handlers, Server Actions, middleware, validation, caching, error handling

92

1.58x
Quality

90%

Does it follow best practices?

Impact

95%

1.58x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
nextjs-api-patterns
description:
Next.js App Router API patterns that must be applied whenever building, extending, or scaffolding a Next.js application. Covers Route Handlers, Server Actions, middleware, error boundaries, validation, streaming, caching, and runtime selection. Apply even when the task only asks for "an API endpoint" or "a form" -- these patterns prevent silent bugs, stale data, and security gaps that are unique to the App Router model.
keywords:
nextjs, next.js, route handler, server action, api route, middleware, app router, server component, client component, data fetching, nextjs error handling, nextjs api, next.js patterns, revalidation, caching, edge runtime, streaming, zod validation, nextresponse, nextrequest, dynamic route, route group, parallel route, use server, next.js 14, next.js 15, app directory, next.js middleware, next.js form, next.js webhook, next.js rest api
license:
MIT

Next.js App Router API Patterns

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.


Why This Matters From Day One

The App Router is a fundamentally different model from Pages Router. Without these patterns from the start:

  • Wrong abstraction chosen -- Route Handlers used for form submissions that should be Server Actions, or Server Actions exposed as public API. Each has a specific purpose.
  • Params accessed wrong -- In Next.js 15+, params is a Promise that must be awaited. Accessing params.id directly returns undefined silently.
  • Stale data served forever -- Route Handlers with GET are cached by default in production. Without explicit cache control, users see outdated data.
  • Mutations don't reflect -- After a Server Action mutates data, the UI shows stale content unless revalidatePath or revalidateTag is called.
  • Middleware runs everywhere -- Without a matcher config, middleware runs on every request including static assets, images, and _next/ paths.
  • Error pages crash -- error.tsx must be a Client Component ('use client'). Omitting this directive causes a build error or runtime crash.

Route Handlers vs Server Actions

Route HandlersServer Actions
Use forExternal API consumers, webhooks, third-party integrations, public REST endpointsForm submissions, mutations from your own UI
Locationapp/api/*/route.ts'use server' functions in dedicated files
HTTP methodsGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONSPOST only (automatic)
Called fromAny HTTP client, external servicesReact components, <form action={...}>
CachingGET is cached by default; POST/PUT/PATCH/DELETE are notNever cached
AuthMust validate manually (headers, tokens)Inherits session from the calling page

Route Handler -- Full Pattern

// 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 }
    );
  }
}

Dynamic Route Handler -- Params Must Be Awaited

// 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 });
}

Server Action -- Full Pattern

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

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).*)',
  ],
};

Key Middleware Rules

  • One middleware.ts per project -- there is no middleware composition. Use conditional logic inside.
  • Always export a config.matcher -- without it, middleware runs on every request including static assets.
  • Middleware runs at the Edge -- no Node.js APIs (no fs, no path, no Node-only libraries). Use only Web APIs.
  • Cannot modify response body for page routes -- can only rewrite, redirect, or set headers.

Error Handling

Route-Level Error Boundary

// 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>
  );
}

Global Not-Found Page

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>Not Found</h2>
      <p>The requested resource does not exist.</p>
    </div>
  );
}

Route Handler Error Responses

All Route Handler errors must follow a consistent shape:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": { "field": ["error message"] }
  }
}

Rules:

  • Always return NextResponse.json(...) with an explicit { status } -- never throw from Route Handlers.
  • Use semantic status codes: 400 validation, 401 unauthorized, 404 not found, 409 conflict, 500 internal.
  • Never leak stack traces, internal paths, or raw database errors to clients.
  • Wrap database/external calls in try/catch and return generic 500 on unexpected errors.

Caching and Revalidation

Route Handler Caching

// 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');
  // ...
}

Server Component Data Fetching

// 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} />;
}

Revalidation After Mutations

// 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');
}

Streaming Responses

// 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' },
  });
}

Edge vs Node.js Runtime

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

Use Edge when: simple request/response transformations, auth checks, redirects. Use Node.js when: database access with native drivers, file system, heavy computation.


Project Structure

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)

Common Gotchas

  1. params is a Promise in Next.js 15+ -- Always await params in Route Handlers and page components. Accessing params.id directly returns undefined.
  2. GET Route Handlers are cached -- Add export const dynamic = 'force-dynamic' or use request-dependent APIs to opt out.
  3. error.tsx must have 'use client' -- It is a Client Component. Forgetting this causes a build/runtime error.
  4. Middleware location -- Must be at the project root (middleware.ts), not inside app/.
  5. Server Actions are POST-only -- They cannot handle GET requests. Use Route Handlers for GET endpoints.
  6. request.json() can throw -- Always wrap in try/catch for Route Handlers that accept JSON bodies.
  7. No res.send() or res.status() -- Route Handlers return NextResponse objects, not Express-style mutation.
  8. Server Actions need revalidatePath/revalidateTag -- Without it, the UI shows stale data after mutation.

Checklist

Every Next.js App Router project must have from the start:

  • Route Handlers for external API / webhooks; Server Actions for internal UI mutations
  • Zod (or equivalent) validation in both Route Handlers and Server Actions
  • await params in all dynamic Route Handlers and pages (Next.js 15+)
  • Structured error responses from Route Handlers: { error: { code, message } }
  • error.tsx with 'use client' directive at route segment level
  • Middleware with config.matcher to exclude static assets
  • revalidatePath/revalidateTag after every mutation in Server Actions
  • Explicit cache control on GET Route Handlers (dynamic = 'force-dynamic' or revalidate)
  • try/catch around request.json() in POST Route Handlers
  • No stack traces or internal errors leaked to API clients

Verifiers

  • route-handlers-used -- Use Route Handlers for external API and Server Actions for mutations
  • route-handler-error-responses -- Structured error responses with appropriate status codes
  • dynamic-params-awaited -- Always await params in dynamic routes (Next.js 15+)
  • middleware-matcher-configured -- Middleware with matcher excluding static assets
  • validation-in-handlers -- Zod validation in Route Handlers and Server Actions
  • cache-control-explicit -- Explicit caching control on GET handlers and data fetching
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/nextjs-api-patterns badge