APIs are the backbone of modern web apps. Next.js provides a first-class framework for building server-side endpoints via API Routes. This post walks through how to build API endpoints with both the Pages Router (pages/api) and the App Router (app/api), implement middleware patterns (Edge Middleware and route-level middleware), and design resilient error-handling strategies for production-ready services.
Why this post: you’ll learn concepts and practical implementations so you can confidently implement APIs in a Next.js app with best practices for security, observability, and maintainability.
Table of contents
-
Introduction
-
Pages Router vs App Router (brief)
-
API basics: creating handlers, methods, responses
-
Dynamic routes & parameters
-
Middleware patterns
- Edge Middleware (global)
- Route-level middleware (HOFs, composition)
-
Error handling strategies
- Centralized wrappers
- Custom Error types
- Validation failures & 422 responses
-
Best practices for production
-
Testing & observability
-
Conclusion & key takeaways
-
Key terms & abbreviations
-
Deployment architecture (text graph)
-
Common pitfalls & best practices
-
Pros/Cons vs alternatives
-
Further resources & alternatives
Pages Router vs App Router (short primer)
- Pages Router: API routes live under
pages/api/*. Handlers receive(req, res)and are Node.js server functions. - App Router (Next 13+): API route files live under
app/api/*/route.(ts|js)and export functions likeGET,POST, etc. Handlers use the Web Fetch APIRequestandNextResponse.
Both approaches are supported; prefer the App Router for new projects since it aligns with the new routing model, but the Pages Router remains useful and familiar.
Key terms & abbreviations (quick reference)
Below are concise definitions for core terms and abbreviations used in this article. Knowing these shortcuts helps when reading docs and code.
- API โ Application Programming Interface: a contract between components that defines how they communicate (requests/responses).
- HTTP โ Hypertext Transfer Protocol: the foundation protocol for web requests (GET, POST, PUT, DELETE, etc.). See MDN: https://developer.mozilla.org/en-US/docs/Web/HTTP
- JSON โ JavaScript Object Notation: a lightweight data-interchange format (commonly used for request/response bodies).
- REST โ Representational State Transfer: an architectural style for designing networked applications (crud-like resources over HTTP).
- CRUD โ Create, Read, Update, Delete: common operations mapped to HTTP verbs.
- JWT โ JSON Web Token: compact token format used for stateless authentication/authorization. https://jwt.io
- CORS โ Cross-Origin Resource Sharing: browser mechanism for allowing cross-origin requests (configure carefully).
- APM โ Application Performance Monitoring: services like Sentry, Datadog, or New Relic that collect error/latency/trace data.
- HOF โ Higher-Order Function: a function that accepts/returns functions (used to wrap handlers with middleware-like behavior).
- CDN โ Content Delivery Network: distributed caching layer (e.g., Cloudflare, Fastly, Vercel CDN).
- TLS โ Transport Layer Security: secure channel for HTTPS.
If any of these are new, the Further resources section at the end contains links for deeper study.
Deployment architecture (text graph)
API Routes run inside your Next.js runtime which can be deployed in multiple ways (Node server, Serverless functions, or Edge runtime). Below are two common deployment deployment topologies shown as simple text graphs.
-
Typical server (Node) deployment:
browser -> CDN -> load balancer -> Next.js Node server -> application services -> database -
Serverless / Edge oriented deployment (Vercel, Netlify, Cloudflare):
browser -> CDN/Edge -> Edge Middleware -> Serverless Function (API Route) -> managed DB / 3rd party API
Notes:
-
Edge middleware runs as close to the user as possible and can short-circuit requests (good for localization, AB tests, auth checks).
-
For high throughput, move stateful features (rate-limits, sessions) to a centralized store such as Redis.
API basics: handlers & HTTP methods
Pages Router (Example)
File: pages/api/todos.js
// pages/api/todos.js
export default async function handler(req, res) {
const { method } = req;
switch (method) {
case 'GET': {
const todos = [ { id: 1, title: 'Buy milk' } ];
return res.status(200).json(todos);
}
case 'POST': {
const { title } = req.body; // Next.js body parser handles JSON by default
// Validate and persist
const created = { id: 2, title };
return res.status(201).json(created);
}
default: {
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).end('Method Not Allowed');
}
}
}
Key points:
- Use status codes appropriately (200, 201, 400, 404, 422, 500).
- Set
Allowheader for 405 responses. - Body parsing for JSON is built-in (unless disabled in config).
App Router (Example)
File: app/api/todos/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const todos = [{ id: 1, title: 'Buy milk' }];
return NextResponse.json(todos);
}
export async function POST(request: Request) {
const body = await request.json();
const created = { id: 2, ...body };
return NextResponse.json(created, { status: 201 });
}
Notes:
- App Router uses
RequestandResponse(orNextResponse) rather than(req, res). - Use
await request.json()to parse a JSON body.
Dynamic routes & query parameters
Pages Router dynamic route
File: pages/api/users/[id].js
export default function handler(req, res) {
const { id } = req.query;
if (req.method === 'GET') {
// fetch user by id...
return res.status(200).json({ id, name: 'Jane' });
}
// ...handle others
}
App Router dynamic route
File: app/api/users/[id]/route.ts
import { NextResponse } from 'next/server';
export async function GET(_request: Request, { params }: { params: { id: string } }) {
const { id } = params;
return NextResponse.json({ id, name: 'Jane' });
}
Middleware patterns
Middleware is useful for cross-cutting concerns: authentication, logging, rate-limiting, request validation, and adding headers.
Request lifecycle (quick overview)
Understanding the order of operations helps debug and design middleware and handlers.
- Client makes request (browser, mobile app, or another service).
- CDN / Edge layer: static assets served from CDN; Edge Middleware can run here and optionally short-circuit.
- Server/Serverless: request hits your Next.js runtime (Node server, serverless container, or edge function); Next runs middleware (global/edge and route-level wrappers) then the matched API handler.
- Handler executes: handler calls business logic and external services (DB, caches, third-party APIs).
- Response sent: Next.js serializes and sends the response; caches/headers are evaluated.
Note: middleware may mutate the request or response and may run in different runtimes (Edge, Node), so align your middleware choices with the expected runtime capabilities.
1) Edge Middleware (global) โ
Edge Middleware runs early in the request pipeline and can modify requests or short-circuit them. Create middleware.ts at your project root.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const auth = req.headers.get('authorization');
if (!auth) {
return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
// Add a request header for downstream APIs
const res = NextResponse.next();
res.headers.set('x-my-middleware', 'ran');
return res;
}
// Only run on API paths (matcher helps reduce runs)
export const config = {
matcher: ['/api/:path*'],
};
Important notes:
- Edge Middleware uses the Edge runtime: avoid Node-only APIs.
- Use
config.matcherto scope middleware (e.g., only/api/:path*).
2) Route-level middleware via Higher-Order Functions (HOFs)
Edge Middleware is global. Often you want middleware-like behavior scoped to specific API handlers. Implement HOFs that wrap handlers.
// lib/withAuth.js
export const withAuth = (handler) => async (req, res) => {
const header = req.headers.authorization || '';
const token = header.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
// verify token (e.g., JWT) and attach user to req
// req.user = decode(token);
return handler(req, res);
};
// usage in pages/api/secure.js
import { withAuth } from '../../lib/withAuth';
export default withAuth(async (req, res) => {
res.json({ secret: 'data' });
});
For App Router you can create HOFs for the exported functions or wrap logic inside the function.
3) Composable middleware utilities
Create small, composable middleware functions and a compose helper so middleware can be reused:
export const compose = (...fns) =>
(handler) => fns.reduceRight((acc, fn) => fn(acc), handler);
// example middleware
export const withLogging = (handler) => async (req, res) => {
console.log(req.method, req.url);
return handler(req, res);
};
// usage
export default compose(withAuth, withLogging)(async (req, res) => {
res.json({ ok: true });
});
4) Validation middleware (using Zod)
Validate request bodies and short-circuit on invalid data.
import { z } from 'zod';
const createTodoSchema = z.object({ title: z.string().min(1) });
export const withValidation = (schema, handler) => async (req, res) => {
try {
const parsed = schema.parse(req.body);
req.validated = parsed;
return handler(req, res);
} catch (err) {
return res.status(422).json({ error: err.errors });
}
};
### Auth example (JWT)
Below is a minimal JWT verification HOF for the Pages Router. In production, use a well-tested library such as `jsonwebtoken` or `jose` and rotate/validate keys safely.
```js
// lib/withJwt.js
import jwt from 'jsonwebtoken'; // or use `jose` for edge/runtime compatibility
export const withJwt = (handler, { secret } = {}) => async (req, res) => {
const auth = req.headers.authorization || '';
const token = auth.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const payload = jwt.verify(token, secret || process.env.JWT_SECRET);
req.user = payload; // attach for downstream handlers
return handler(req, res);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
};
If you’re using Edge Middleware (Edge runtime) prefer jose which supports Web Crypto APIs and is runtime-friendly: https://github.com/panva/jose
Rate limiting example (simple / in-memory)
This in-memory rate limiter is only suitable for development or low-traffic apps. For production, use a centralized store such as Redis (e.g., ioredis) or a provider-based solution.
// lib/rateLimit.js (very small demo)
const hits = new Map();
export const rateLimit = ({ limit = 100, windowMs = 60_000 } = {}) => (handler) => async (req, res) => {
const key = req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'anon';
const now = Date.now();
const entry = hits.get(key) || { count: 0, start: now };
if (now - entry.start > windowMs) {
entry.count = 0;
entry.start = now;
}
entry.count += 1;
hits.set(key, entry);
if (entry.count > limit) {
return res.status(429).json({ error: 'Too Many Requests' });
}
return handler(req, res);
};
App Router: TypeScript HOF and validation using Zod
App Router handlers can be wrapped similarly. Here’s a small example that wraps exported functions with a validation step using Zod.
// app/api/todos/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const createTodo = z.object({ title: z.string().min(1) });
const validate = (schema: any) => async (fn: any, req: Request, ctx: any) => {
try {
const body = await req.json();
schema.parse(body);
return fn(req, ctx);
} catch (err) {
return NextResponse.json({ error: err.errors || err.message }, { status: 422 });
}
};
export async function POST(request: Request, ctx: any) {
return validate(createTodo)(async (req: Request) => {
const body = await req.json();
const created = { id: Date.now(), ...body };
return NextResponse.json(created, { status: 201 });
}, request, ctx);
}
Error handling strategies
A robust error handling approach improves developer experience, reliability, and observability.
1) Centralized wrapper for Pages Router
Create a small wrapper that catches exceptions and returns consistent responses.
// lib/apiHandler.js
export const apiHandler = (handler) => async (req, res) => {
try {
await handler(req, res);
} catch (err) {
console.error(err);
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
// Do NOT expose stack traces to clients in production
res.status(status).json({ error: message });
}
};
// usage
export default apiHandler(async (req, res) => {
// handler code that can throw
});
2) App Router error handling
For App Router route.ts handlers, use try/catch and return NextResponse with proper status.
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
try {
const body = await request.json();
// ...do stuff that could throw
return NextResponse.json({ ok: true }, { status: 201 });
} catch (err) {
console.error(err);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
3) Custom error classes
Using typed errors helps map exceptions to HTTP responses cleanly.
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
throw new HttpError(404, 'Not found');
The wrapper can check err instanceof HttpError and use err.status.
Operational vs programmer errors, retries & idempotency
- Operational errors are transient or environmental (network timeouts, DB connection issues) and are often safe to retry using exponential backoff.
- Programmer errors (bugs like undefined variables) should be fixed in code โ do not retry automatically.
- Idempotency: for operations that can be retried (e.g., payment capture), accept and honor idempotency keys so replays do not cause duplicate side effects.
When implementing retries, use client-side or server-side backoff policies and avoid tightly coupling retries into the request path for synchronous user requests.
4) Validation errors & status codes
- Use 422 Unprocessable Entity for validation failures (i.e., request syntax is fine but semantically invalid).
- Use 400 Bad Request for malformed requests.
- Use 401 Unauthorized and 403 Forbidden for auth-related failures.
5) Logging, monitoring & non-blocking reporting
- Log errors with a structured logger (e.g., Winston, Pino) including request id, route, and user id.
- Report important errors to an APM (Sentry, Datadog) but avoid blocking request handling to external services.
Observability: logging & error reporting (example)
Instrumenting and monitoring your API is essential for running reliably in production.
Example: simple Pino logger wrapper and non-blocking Sentry capture.
// lib/logger.js
import pino from 'pino';
export const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
// lib/errorReporter.js
import * as Sentry from '@sentry/node';
Sentry.init({ dsn: process.env.SENTRY_DSN });
export const reportError = (err, context = {}) => {
// non-blocking capture
Sentry.captureException(err, { extra: context });
};
// usage in wrapper
export const apiHandlerWithLogging = (handler) => async (req, res) => {
try {
await handler(req, res);
} catch (err) {
logger.error({ err, url: req.url }, 'Unhandled error in API');
reportError(err, { url: req.url });
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
}
};
Keep in mind:
-
Avoid blocking the request while reporting errors to external services; use background workers, buffering, or non-blocking SDK features.
-
Include request id, user id (if available), and route in logs to make traces easier to correlate.
Best practices & tips for production ๐
- Use proper HTTP status codes and consistent response shapes.
- Don’t leak stack traces in production; return safe, useful error messages to clients, and log full errors server-side.
- Rate-limit critical endpoints (login, search).
- Use CORS only when needed; configure allowed origins explicitly.
- Validate inputs (Zod, Joi, Yup) and reject early with 4xx statuses.
- Keep middleware scoped & composable (HOFs + compose helper).
- Use environment variables for secrets and connection strings and keep them out of code.
- Use caching and headers (Cache-Control) where appropriate for GETs.
- Be mindful of runtime: Edge Middleware uses Edge runtime โ some Node APIs (fs, crypto certain usage) may not be available.
- Test your API routes (unit tests for handler logic and integration tests for route plumbing).
Common pitfalls & how to avoid them โ ๏ธ
- Mixing business logic and route handlers โ keep handlers thin and test the business logic independently in service modules.
- Leaking sensitive error details โ return sanitized messages to clients and log full details to APM only.
- Using in-memory state for cross-instance concerns โ use Redis or managed stores for distributed rate-limiting, counters, or sessions.
- Edge runtime surprises โ the Edge runtime has different APIs and limitations (no filesystem access, limited Node APIs); prefer Web Crypto-friendly libs like
jose. - Ignoring idempotency โ design non-idempotent endpoints (e.g., payments) to accept idempotency keys to safely retry.
- Poor CORS configuration โ avoid
Access-Control-Allow-Origin: *for credentialed requests; limit allowed origins strictly.
Testing tips & examples
- Use supertest + Jest for integration tests that exercise the actual HTTP layer.
- Prefer unit tests for business logic: move logic to services and test those directly. Example (Jest):
// services/todoService.js
export const createTodo = (data) => {
if (!data.title) throw { status: 422, message: 'title required' };
return { id: Date.now(), ...data };
};
// __tests__/todoService.test.js
import { createTodo } from '../services/todoService';
test('createTodo requires title', () => {
expect(() => createTodo({})).toThrow();
});
For App Router route functions (unit test) you can call the handler directly:
import { POST } from '../../app/api/todos/route';
test('POST creates todo', async () => {
const req = new Request('https://example.com', { method: 'POST', body: JSON.stringify({ title: 'x' }), headers: { 'content-type': 'application/json' } });
const res = await POST(req, { params: {} });
// assert on NextResponse status/json - depends on runtime test harness
});
Pros & cons: Next.js API Routes vs alternatives โ๏ธ
Pros
- Built into Next.js with great DX and co-location with frontend code.
- Supports Edge runtime and serverless deployments on Vercel and other platforms.
Cons
-
Not as feature-rich as dedicated frameworks (Express, Fastify, NestJS) for complex backend needs.
-
For very large APIs, a separate backend service or microservice architecture is often preferable.
-
Use supertest + Jest for testing Pages Router endpoints.
-
For App Router, you can test the exported functions directly (unit tests) and use integration tests hitting the deployed URL.
-
Mock external services and isolate business logic in small modules that are easy to unit test.
Conclusion โ Key takeaways โ
- Next.js provides two ways to write server endpoints; the App Router is the modern approach using
route.tshandlers, while the Pages Router usespages/api. - Use Edge Middleware for global concerns and HOF-based middleware for route-specific logic.
- Centralize error handling and use custom error types to map exceptions to HTTP statuses consistently.
- Validate inputs, set proper status codes, and protect endpoints (auth, rate-limits).
With these patterns you can build reliable, maintainable, and production-ready APIs in Next.js.
Further reading
- Next.js docs: https://nextjs.org/docs
- Zod: https://github.com/colinhacks/zod
Further resources & alternatives
Official docs & specs
- Next.js API Routes (Pages Router): https://nextjs.org/docs/api-routes/introduction
- Next.js App Router - Route Handlers: https://nextjs.org/docs/app/building-your-application/routing/router-handlers
- MDN Web Docs โ HTTP: https://developer.mozilla.org/en-US/docs/Web/HTTP
- RFC 7231 (HTTP/1.1 Semantics and Content): https://tools.ietf.org/html/rfc7231
Security & best practices
- OWASP Top 10: https://owasp.org/www-project-top-ten/
- API Security (OWASP): https://owasp.org/www-project-api-security/
Tools & libraries
- Zod (validation): https://github.com/colinhacks/zod
- jose (JWT & JWK, Edge-friendly): https://github.com/panva/jose
- Pino logger: https://getpino.io
- Sentry (error tracking): https://sentry.io
Alternatives to consider
- Express: https://expressjs.com/
- Fastify: https://www.fastify.io/
- tRPC: https://trpc.io/
- GraphQL & Apollo: https://www.apollographql.com/
- NestJS: https://nestjs.com/
Articles & learning resources
- API design best practices (Postman): https://learning.postman.com/docs/designing-and-developing-your-api/best-practices/
- HTTP status codes explained (MDN): https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
Comments