Using API routes in Next.js with error handling and type safety

Using API routes in Next.js with error handling and type safety

Build resilient, type-safe APIs in Next.js by combining schema validation, structured error handling, and reusable wrappers that keep server logic predictable and client contracts consistent.

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:

  1. Define shared types and schemas
  2. Wrap route handlers in a generic “error wrapper” / middleware
  3. Use schema validation for inputs and outputs (e.g. Zod / io-ts)
  4. Use custom error classes for domain-level errors
  5. Format errors into a consistent envelope
  6. 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 is any — not typed by TypeScript
  • safeParse vs parse: safeParse returns union results you must handle; parse throws, so use inside try/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.

Was this helpful?

Thanks for your feedback!

Leave a comment

Your email address will not be published. Required fields are marked *