React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

apiroute-handlersserver-actionsrestrules

30 API Rules for Next.js

Rules for building APIs with Next.js Route Handlers and Server Actions. Covers design, security, validation, error handling, and operational concerns.

Design & Structure (Rules 1-10)

1. Use Route Handlers for external consumers, Server Actions for internal forms. Route Handlers (GET, POST in route.ts) serve external clients, webhooks, and third-party integrations. Server Actions serve your own UI.

Use CaseApproach
Form submission from your UIServer Action
Webhook from Stripe/GitHubRoute Handler
REST API for mobile appRoute Handler
Button click mutationServer Action
CORS-enabled public APIRoute Handler

2. Organize Route Handlers by resource. Follow RESTful conventions in your file structure.

app/
  api/
    users/
      route.ts          # GET (list), POST (create)
      [id]/
        route.ts        # GET (detail), PATCH (update), DELETE
    posts/
      route.ts
      [slug]/
        route.ts

3. Use proper HTTP methods. GET for reads, POST for creates, PATCH for partial updates, PUT for full replacements, DELETE for removals. Never use GET for mutations.

4. Return consistent response shapes. Every endpoint should return the same structure for success and error.

// Success
return NextResponse.json({ data: user }, { status: 200 });
 
// Error
return NextResponse.json(
  { error: { code: "NOT_FOUND", message: "User not found" } },
  { status: 404 }
);
 
// List with pagination
return NextResponse.json({
  data: users,
  pagination: { page, pageSize, total },
});

5. Use proper HTTP status codes.

CodeWhen
200Success (GET, PATCH, DELETE)
201Created (POST)
204No content (DELETE with no body)
400Bad request (validation failed)
401Unauthorized (no auth)
403Forbidden (auth present, insufficient permissions)
404Not found
409Conflict (duplicate resource)
422Unprocessable entity (valid format, invalid data)
429Rate limited
500Internal server error

6. Version your API if it has external consumers. Use path-based versioning for external APIs.

app/api/v1/users/route.ts
app/api/v2/users/route.ts

7. Keep Route Handlers thin. Route Handlers should parse input, call a service function, and return a response. Business logic belongs in lib/ or services/.

// app/api/users/route.ts
import { createUser } from "@/services/users";
 
export async function POST(request: NextRequest) {
  const body = await request.json();
  const result = await createUser(body);  // Logic lives elsewhere
  return NextResponse.json({ data: result }, { status: 201 });
}

8. Use typed request and response helpers. Create utility functions for common patterns.

// lib/api.ts
export function success<T>(data: T, status = 200) {
  return NextResponse.json({ data }, { status });
}
 
export function error(code: string, message: string, status: number) {
  return NextResponse.json({ error: { code, message } }, { status });
}
 
// Usage
return success(user, 201);
return error("NOT_FOUND", "User not found", 404);

9. Accept and validate query parameters for GET endpoints. Use searchParams from the URL and validate with Zod.

import { z } from "zod";
 
const querySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(20),
  search: z.string().optional(),
  sort: z.enum(["name", "createdAt", "updatedAt"]).default("createdAt"),
});
 
export async function GET(request: NextRequest) {
  const params = Object.fromEntries(request.nextUrl.searchParams);
  const parsed = querySchema.safeParse(params);
  if (!parsed.success) {
    return error("VALIDATION_ERROR", "Invalid parameters", 400);
  }
  const { page, pageSize, search, sort } = parsed.data;
  // ... fetch data
}

10. Validate request bodies with Zod for POST/PATCH/PUT. Parse the body and return structured validation errors.

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  role: z.enum(["user", "admin"]).default("user"),
});
 
export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = createUserSchema.safeParse(body);
 
  if (!parsed.success) {
    return NextResponse.json(
      { error: { code: "VALIDATION_ERROR", details: parsed.error.flatten() } },
      { status: 400 }
    );
  }
 
  const user = await db.user.create({ data: parsed.data });
  return NextResponse.json({ data: user }, { status: 201 });
}

Security (Rules 11-18)

11. Authenticate every non-public endpoint. Check the session or API key at the top of every Route Handler and Server Action.

export async function GET(request: NextRequest) {
  const session = await auth();
  if (!session) {
    return error("UNAUTHORIZED", "Authentication required", 401);
  }
  // ... proceed
}

12. Authorize based on resource ownership. Authentication proves identity. Authorization proves access. Always check that the user owns or has permission to access the resource.

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) return error("UNAUTHORIZED", "Auth required", 401);
 
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } });
  if (!post) return error("NOT_FOUND", "Post not found", 404);
  if (post.authorId !== session.user.id && session.user.role !== "admin") {
    return error("FORBIDDEN", "Not authorized", 403);
  }
 
  await db.post.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

13. Validate webhook signatures. For Stripe, GitHub, and other webhook providers, always verify the signature before processing.

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature")!;
 
  try {
    const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
    // Process event
  } catch {
    return error("INVALID_SIGNATURE", "Invalid webhook signature", 400);
  }
}

14. Rate limit your API endpoints. Protect against abuse with rate limiting. Use in-memory stores for development, Redis for production.

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "10 s"),
});
 
export async function POST(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
  const { success } = await ratelimit.limit(ip);
  if (!success) {
    return error("RATE_LIMITED", "Too many requests", 429);
  }
  // ... proceed
}

15. Never expose internal error details. Log the full error server-side. Return a generic message to the client.

try {
  const result = await riskyOperation();
  return success(result);
} catch (err) {
  console.error("Internal error:", err);  // Full error in logs
  return error("INTERNAL_ERROR", "Something went wrong", 500);  // Generic to client
}

16. Use CORS headers only when needed. Only add CORS for endpoints consumed by external frontends. Misconfigured CORS is a security risk.

export async function OPTIONS() {
  return new NextResponse(null, {
    headers: {
      "Access-Control-Allow-Origin": "https://trusted-domain.com",
      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

17. Sanitize user input before database queries. Even with ORMs like Prisma (which parameterize queries), validate and sanitize input. Never interpolate user input into raw SQL.

18. Set appropriate cache headers. Public GET endpoints should specify caching. Private endpoints should prevent caching.

// Public, cacheable
return NextResponse.json({ data }, {
  headers: { "Cache-Control": "public, max-age=60, s-maxage=300" },
});
 
// Private, no cache
return NextResponse.json({ data }, {
  headers: { "Cache-Control": "private, no-cache, no-store" },
});

Error Handling & Reliability (Rules 19-24)

19. Handle all error cases explicitly. Check for not found, unauthorized, validation errors, and conflict states. Do not rely on catch-all error handling for expected cases.

20. Use try/catch in every Route Handler. Wrap the entire handler body to prevent unhandled exceptions from crashing the process.

export async function GET(request: NextRequest) {
  try {
    const session = await auth();
    if (!session) return error("UNAUTHORIZED", "Auth required", 401);
 
    const data = await fetchData();
    return success(data);
  } catch (err) {
    console.error("GET /api/data failed:", err);
    return error("INTERNAL_ERROR", "Something went wrong", 500);
  }
}

21. Make webhook handlers idempotent. Webhooks may be delivered multiple times. Store the event ID and skip duplicates.

export async function POST(request: NextRequest) {
  const event = await verifyWebhook(request);
 
  // Check if already processed
  const existing = await db.webhookEvent.findUnique({
    where: { eventId: event.id },
  });
  if (existing) return success({ received: true });
 
  // Process and record
  await db.$transaction([
    processEvent(event),
    db.webhookEvent.create({ data: { eventId: event.id } }),
  ]);
 
  return success({ received: true });
}

22. Return early for invalid states. Check preconditions at the top. Each check returns immediately if invalid. The happy path is the code that reaches the end.

23. Log structured data, not strings. Use structured logging for easier filtering and alerting in production.

console.error({
  event: "api.users.create.failed",
  userId: session.user.id,
  error: err.message,
  stack: err.stack,
  timestamp: new Date().toISOString(),
});

24. Set timeouts for external API calls. Use AbortController with a timeout to prevent hanging requests.

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
 
try {
  const res = await fetch(externalUrl, { signal: controller.signal });
  return success(await res.json());
} catch (err) {
  if (err instanceof DOMException && err.name === "AbortError") {
    return error("TIMEOUT", "External service timed out", 504);
  }
  throw err;
} finally {
  clearTimeout(timeout);
}

Performance & Operations (Rules 25-30)

25. Use streaming for large responses. Return ReadableStream for CSV exports, large JSON arrays, or real-time data.

26. Paginate list endpoints. Never return unbounded lists. Default to reasonable page sizes (20-50) with a maximum cap (100).

27. Use database connection pooling. Create a singleton Prisma client or connection pool. Do not create a new connection per request.

// lib/db.ts
import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
 
export const db = globalForPrisma.prisma || new PrismaClient();
 
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

28. Use edge runtime for latency-sensitive endpoints. Simple auth checks, redirects, and lightweight transformations benefit from edge runtime.

export const runtime = "edge";
 
export async function GET(request: NextRequest) {
  // Runs at the edge, closer to the user
}

29. Monitor API response times and error rates. Track P50, P95, and P99 latencies. Alert on error rate spikes. Use Vercel Analytics, Datadog, or similar.

30. Document your API. Maintain an OpenAPI spec or at minimum a README with endpoints, request/response examples, and authentication requirements. Your future self and your team will thank you.


FAQs

When should you use a Route Handler vs. a Server Action?
  • Route Handler: external consumers (mobile apps, third-party integrations), webhooks, CORS-enabled public APIs
  • Server Action: internal form submissions and UI mutations from your own Next.js app
What response shape should every API endpoint return?
// Success
{ data: T }
 
// Error
{ error: { code: string, message: string } }
 
// List
{ data: T[], pagination: { page, pageSize, total } }

Consistency makes client-side parsing predictable.

Why should you keep Route Handlers thin?
  • Route Handlers should only parse input, call a service function, and return a response
  • Business logic belongs in lib/ or services/ for testability and reuse
  • Thin handlers are easier to test, debug, and swap between Route Handlers and Server Actions
How do you validate query parameters with Zod in a GET handler?
const querySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  pageSize: z.coerce.number().min(1).max(100).default(20),
});
 
export async function GET(request: NextRequest) {
  const params = Object.fromEntries(request.nextUrl.searchParams);
  const parsed = querySchema.safeParse(params);
  if (!parsed.success) return error("VALIDATION_ERROR", "Invalid params", 400);
}
Gotcha: What happens if you forget to verify webhook signatures?
  • Any attacker can send fake webhook payloads to your endpoint
  • Without signature verification, you may process fraudulent events (e.g., fake Stripe payment confirmations)
  • Always verify using the provider's secret and the request signature header
How do you make webhook handlers idempotent?
  • Store the event ID in your database when you process a webhook
  • On each request, check if the event ID already exists
  • Skip processing if it does -- webhooks may be delivered multiple times
How should you type a Route Handler with dynamic params in TypeScript?
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  // ...
  return new NextResponse(null, { status: 204 });
}
How do you create typed response helpers in TypeScript?
export function success<T>(data: T, status = 200) {
  return NextResponse.json({ data }, { status });
}
 
export function error(code: string, message: string, status: number) {
  return NextResponse.json({ error: { code, message } }, { status });
}
What is the difference between 401 and 403 status codes?
  • 401 Unauthorized: no authentication provided or session is invalid
  • 403 Forbidden: authentication is present but the user lacks permission for this resource
  • Check auth first (401), then check permissions (403)
Gotcha: Why should you never return internal error details to the client?
  • Stack traces and error messages can reveal file paths, library versions, and database schemas
  • Attackers use these details to find vulnerabilities
  • Log the full error server-side; return a generic "Something went wrong" to the client
How do you set a timeout for external API calls in a Route Handler?
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
 
try {
  const res = await fetch(url, { signal: controller.signal });
  return success(await res.json());
} catch (err) {
  if (err instanceof DOMException && err.name === "AbortError") {
    return error("TIMEOUT", "External service timed out", 504);
  }
  throw err;
} finally {
  clearTimeout(timeout);
}
Why is database connection pooling important in serverless/Next.js environments?
  • Each request could create a new database connection without pooling
  • This exhausts database connection limits quickly under load
  • Use a singleton pattern (e.g., global Prisma client) to reuse connections across requests

Quick Reference

CategoryKey Rule
DesignConsistent response shapes, proper HTTP methods and status codes
SecurityAuth on every endpoint, validate all input, rate limit, verify webhooks
ErrorsTry/catch everything, return early, log structured data
PerformancePaginate, pool connections, stream large responses
OperationsMonitor latency, make webhooks idempotent, document everything