Simple, type-safe REST APIs in Next.js

An opinionated approach to achieving type safety in your Next.js project without sacrificing developer experience or the simplicity of REST APIs

Getting started

INSTALLATION

npm install typed-handlers
CONFIGURATION

The package exposes a next.js plugin that is used to generate the route definitions. You will need to add the following to your next.config.js file. If you are using this with the pages router, you will need to set the legacy option to true

// next.config.js

import { withTypedHandlers } from 'typed-handlers/next';

export default withTypedHandlers({
	//... next config 
},{
 legacy: true // pages router
});

Once you've added this to your config, you'll notice that a new file typed-handlers.d.ts is created add added to the include options in your tsconfig.json file. If this doesn't happen automatically for whatever reason, ensure your setup resembles the following

// typed-handlers.d.ts

/// <reference types='typed-handlers/routes' />
// tsconfig.json

{
  // ...
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    "typed-handlers.d.ts"
  ],
}

And that's it! In dev mode, the package will watch for changes/new api routes and generate the necessary types for you to use in your app. In production, the package will generate the types at build time.

Example usage

// app/api/hello.ts

import { NextResponse } from "next/server";
import { createHandler, schema } from "typed-handlers";
import { z } from "zod";

export const POST = createHandler(
  async ({ body }) => {
    return NextResponse.json({
      message: `Hello, ${body.name}!`,
    });
  },
  {
    schema: schema({
      body: z.object({
        name: z.string({ required_error: "Name is required" }),
      }),
    }),
  }
);

The example above demonstrates a simple POST request handler. It's similar to the default Next.js API route handler, but with a few key differences:

  • The route handler is wrapped in the createHandler utility function from the typed-handlers package. The second argument is an options object that contains a schema property which is used to define the input and output schemas for validation and type safety.
  • To allow for output validation, the function must return a NextResponse object instead of the usual Response object. NextResponse is generic while Response is not.
  • The function signature is slightly different. It receives an enriched object as its only argument. It contains the following properties: request, context, body, query, and params which have all been validated (where applicable) and typed correctly.
  • request and context come from the Next.js API route handler while body, query, and params are the parsed request body, query string, and route parameters respectively (using zod objects provided in the schema).

We can expand on this example to make it more full-featured as follows

// app/api/hello.ts

import { NextResponse } from "next/server";
import { createHandler, schema } from "typed-handlers";
import { z } from "zod";

export const POST = createHandler(
  async ({ body, query }) => {
    return NextResponse.json({
      // output will be validated
      message: `Hello, ${body.name}! You've requested page ${query.page}`,
    });
  },
  {
    schema: schema({
      body: z.object({
        name: z.string(),
      }),
      output: z.object({
        message: z.string(),
      }),
      query: z.object({
        page: z.coerce.number().optional().default(1),
      }),
    }),
    onValidationError: ({ source, error }) => {
      console.error(source); // -> 'body' | 'query'
      console.error(error); // -> 'tis a ZodError instance
      return NextResponse.json(
        { error: `Invalid request ${source}`, issues: error.issues },
        { status: 422 }
      );
    },
  }
);

It's important to note that the package automatically handles any validation errors and returns a 400 error code with an error title and description. The onValidationError option can be used to customize this behavior.

TYPING DYNAMIC ROUTES

While type-safety to input and output validation utilize the zod schemas provided, type-safety for dynamic routes (therefore, params) is a bit different. It happens via route inference i.e The package will infer the types of the route parameters from the route itself. And since the routes are typed, you can be sure that the types are correct.