Next.js was originally conceived as a React framework combining server-side rendering (SSR), static site generation, and client-side navigation. Over time, the team (Vercel) realized that developers often need backend APIs alongside their front-end logic. Thus came API routes under the pages/api/
directory: server-only endpoints co-located with your Next.js app.
With the advent of Next.js 13 and the App Router, the paradigm shifted: instead of pages/api
, you may now define route handlers in the app
folder (e.g. route.ts
). But many projects still use the Pages Router, and even in new ones, you might mix both.
Over time, as applications grow, simple try/catch
error handling proves insufficient, and untyped payloads introduce bugs. Thus best practices have emerged around centralized error logic, schema-based validation (e.g. using Zod), and strong TypeScript definitions (so clients and servers agree). Some open-source frameworks built on these ideas (e.g. Next REST Framework) aim for type-safe, self-documenting APIs atop Next.js.
In what follows, I’ll assume you are using Next.js 14+ (App Router available but you might still use pages-based API routes), and that you use TypeScript. I’ll show you patterns for both “pages API routes” and “app route handlers” with robust error and type safety.
Anatomy of a Next.js API route (Pages Router)
A file under pages/api/
becomes an HTTP endpoint. It receives (req, res)
parameters typed as NextApiRequest
and NextApiResponse
.
By default, req.body
, req.query
, and req.cookies
are loosely typed (any
or string | string[]
) — you must validate them yourself. The route may export a config
object to tweak parsing behavior (e.g. disabling body parsing).
A minimal example:
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next';
type HelloResponse = { message: string };
export default function handler(
req: NextApiRequest,
res: NextApiResponse<HelloResponse>
) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
res.status(200).json({ message: 'Hello from Next.js!' });
}
If you have async logic, wrap in try/catch
:
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<HelloResponse | { error: string }>
) {
try {
const data = await getSomeData();
return res.status(200).json({ message: data });
} catch (err) {
console.error('Unhandled error in API route:', err);
return res.status(500).json({ error: 'Internal Server Error' });
}
}
But this approach has limitations. You still must (1) guard methods, (2) validate input shape, (3) consistently format error responses, (4) avoid repetition, and (5) ensure your client and server agree on types.
Route Handlers in the App Router
In the App Router (Next.js 13+), instead of pages/api/...
, you place route.ts
(or route.js
) files inside the app
folder. These expose named HTTP method exports (e.g. export async function GET(...) { ... }
). Example:
// app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
try {
const users = await fetchUsers();
return NextResponse.json(users);
} catch (error) {
console.error('error in GET /api/users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
}
The Request
here is the native Web API Request
, and NextResponse
helps you construct JSON responses. Note that the built-in Next.js error boundary mechanism (via error.js
) does not automatically apply to route handlers, so errors in those must still be caught and translated to valid HTTP responses.
Also, because route.ts
lives in the file structure, you can segment APIs by folder hierarchies similar to pages.
A layered approach to error handling & type safety
To scale well, I recommend the following layered structure:
- Define shared types and schemas
- Wrap route handlers in a generic “error wrapper” / middleware
- Use schema validation for inputs and outputs (e.g. Zod / io-ts)
- Use custom error classes for domain-level errors
- Format errors into a consistent envelope
- Ensure clients’ fetch typing matches server responses
Below is a conceptual flow:
flowchart TD ClientRequest --> RouteHandler RouteHandler --> InputValidation InputValidation --> BusinessLogic BusinessLogic --> OutputValidation BusinessLogic --> ThrowsDomainError ThrowsDomainError --> ErrorWrapper OutputValidation --> SendSuccess ErrorWrapper --> SendError SendError --> ClientResponse SendSuccess --> ClientResponse

This ensures that:
- You validate the shape of incoming data (body, query) before touching business logic.
- If business logic throws a known error (e.g. “NotFoundError”), you catch it and convert it to a well-defined HTTP status + payload.
- You validate output shape (optionally) to maintain invariants.
- All errors map to a consistent client contract.
Example: Full typed API route with Zod + wrapper (Pages Router)
First, define some shared types and Zod schemas:
// lib/schemas.ts
import { z } from 'zod';
export const CreateUserInput = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export type CreateUserInput = z.infer<typeof CreateUserInput>;
export const UserResponse = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
export type UserResponse = z.infer<typeof UserResponse>;
export class ApiError extends Error {
status: number;
data?: unknown;
constructor(status: number, message: string, data?: unknown) {
super(message);
this.status = status;
this.data = data;
}
}
Then build a generic wrapper:
// lib/withApi.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { ZodSchema } from 'zod';
type Handler<I, O> = (
input: I,
req: NextApiRequest,
res: NextApiResponse
) => Promise<O>;
export function withApi<I, O>(
schemaIn: ZodSchema<I> | null,
handler: Handler<I, O>
) {
return async function (
req: NextApiRequest,
res: NextApiResponse<O | { error: string; details?: unknown }>
) {
try {
let input: I = {} as I;
if (schemaIn) {
const parseResult = schemaIn.safeParse(
req.method === 'GET' ? req.query : req.body
);
if (!parseResult.success) {
throw new ApiError(400, 'Invalid request', parseResult.error);
}
input = parseResult.data;
}
const output = await handler(input, req, res);
// Optionally validate output too (not shown)
return res.status(200).json(output);
} catch (err) {
console.error('API route error:', err);
if (err instanceof ApiError) {
return res
.status(err.status)
.json({ error: err.message, details: err.data });
}
return res.status(500).json({ error: 'Internal Server Error' });
}
};
}
Finally, use it in your route:
// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { withApi } from '../../lib/withApi';
import { CreateUserInput, UserResponse, ApiError } from '../../lib/schemas';
async function handler(
input: CreateUserInput,
req: NextApiRequest,
_res: NextApiResponse
): Promise<UserResponse> {
if (req.method !== 'POST') {
throw new ApiError(405, 'Method Not Allowed');
}
const { name, email } = input;
// imaginary business logic
const user = await createUser({ name, email });
if (!user) {
throw new ApiError(500, 'User creation failed');
}
return user;
}
export default withApi(CreateUserInput, handler);
With this pattern:
- Invalid input → HTTP 400 with structured error
- Business errors → mapped to correct status via
ApiError
- Unexpected errors → HTTP 500
- Response always matches typed shape
If you also validate the output shape (using Zod or similar), you guard against internal regressions.
Example: Route handler version (App Router) with type safety
In app/api/users/route.ts
:
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { ApiError } from '@/lib/schemas';
const CreateUserInput = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type CreateUserInput = z.infer<typeof CreateUserInput>;
const UserResponse = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
type UserResponse = z.infer<typeof UserResponse>;
export async function POST(request: Request) {
try {
const body = await request.json();
const parsed = CreateUserInput.safeParse(body);
if (!parsed.success) {
throw new ApiError(400, 'Invalid body', parsed.error);
}
const { name, email } = parsed.data;
const user = await createUser({ name, email });
if (!user) {
throw new ApiError(500, 'User creation failed');
}
const validated = UserResponse.parse(user);
return NextResponse.json(validated, { status: 201 });
} catch (err) {
console.error('POST /api/users error:', err);
if (err instanceof ApiError) {
return NextResponse.json(
{ error: err.message, details: err.data },
{ status: err.status }
);
}
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
You could factor out the error wrapper into a higher-order function analogous to withApi
(though simpler, because route handlers accept Request
and return Response
or NextResponse
).
One nuance: in route handlers, the Request
is the web-standard Request
type, not NextApiRequest
. So you lose some built-in parsing convenience (cookies, query) unless you wrap that logic.
Additional patterns & caveats
Instead of always using ApiError
, you might define a hierarchy:
class NotFoundError extends ApiError { constructor(msg) { super(404, msg); } }
class UnauthorizedError extends ApiError { constructor(msg) { super(401, msg); } }
// etc.
Then in business logic you can simply throw new NotFoundError('User not found')
, and your wrapper takes care of conversion.
Centralizing error mapping
If you have many endpoints, keep one module that maps error types → HTTP status + shape. That way you don’t repeat if (err instanceof X)
everywhere.
Logging vs exposing
In production, you should avoid leaking internal stack traces or error details to clients. Use generic messages (e.g. “Internal Server Error”) and log full errors server-side. In development, you may include more context. Next.js’s App Router error boundaries help with rendering UI-level errors, but API routes still require manual throwing-to-response conversion.
Input sizes and parsing
If your API handles large payloads or webhooks (e.g. Stripe), you may disable Next.js’s default body parser (api.config.bodyParser = false
) and read raw bodies for signature verification.
Streaming responses
API routes support streaming via res.writeHead
+ res.write
in pages/api
. But with error handling, ensure you catch early and close the stream gracefully if something fails.
Type safety trade-offs
Even with TypeScript and schema validation, you must be aware:
NextApiRequest.body
isany
— not typed by TypeScriptsafeParse
vsparse
:safeParse
returns union results you must handle;parse
throws, so use insidetry/catch
- Over-validating responses can cost performance
- Any third-party library (e.g. database) may return data that violates your expectation — validate before returning
Some libraries aim to relieve this boilerplate: for example Next REST Framework wraps Next.js APIs with Zod + auto-OpenAPI, enabling type-safe definitions and docs.
Summary & recommendations
You can treat Next.js API routes (in pages/api/
) and App Router route handlers as first-class backend endpoints in your full-stack app. But the naive way (ad hoc try/catch
) does not scale. The robust approach is:
- Define shared TypeScript types + runtime schemas
- Wrap handlers in reusable error wrappers / middleware
- Use domain-specific error classes
- Format responses in a consistent error envelope
- Avoid leaking internal error info
- Keep client and server types in sync
If your project is large, consider adopting or building a small internal framework (or using community tools) to enforce these patterns uniformly.