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.
Search across all documentation pages
Rules for building APIs with Next.js Route Handlers and Server Actions. Covers design, security, validation, error handling, and operational concerns.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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 Case | Approach |
|---|---|
| Form submission from your UI | Server Action |
| Webhook from Stripe/GitHub | Route Handler |
| REST API for mobile app | Route Handler |
| Button click mutation | Server Action |
| CORS-enabled public API | Route 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.
| Code | When |
|---|---|
| 200 | Success (GET, PATCH, DELETE) |
| 201 | Created (POST) |
| 204 | No content (DELETE with no body) |
| 400 | Bad request (validation failed) |
| 401 | Unauthorized (no auth) |
| 403 | Forbidden (auth present, insufficient permissions) |
| 404 | Not found |
| 409 | Conflict (duplicate resource) |
| 422 | Unprocessable entity (valid format, invalid data) |
| 429 | Rate limited |
| 500 | Internal 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 });
}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" },
});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);
}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.
// Success
{ data: T }
// Error
{ error: { code: string, message: string } }
// List
{ data: T[], pagination: { page, pageSize, total } }Consistency makes client-side parsing predictable.
lib/ or services/ for testability and reuseconst 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);
}export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// ...
return new NextResponse(null, { status: 204 });
}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 });
}"Something went wrong" to the clientconst 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);
}| Category | Key Rule |
|---|---|
| Design | Consistent response shapes, proper HTTP methods and status codes |
| Security | Auth on every endpoint, validate all input, rate limit, verify webhooks |
| Errors | Try/catch everything, return early, log structured data |
| Performance | Paginate, pool connections, stream large responses |
| Operations | Monitor latency, make webhooks idempotent, document everything |
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥