Skip to content

Instantly share code, notes, and snippets.

@devrnt
Last active December 20, 2022 19:13
Show Gist options
  • Save devrnt/cacaf4d91d34c6363dd7bb3cc5d9a6e9 to your computer and use it in GitHub Desktop.
Save devrnt/cacaf4d91d34c6363dd7bb3cc5d9a6e9 to your computer and use it in GitHub Desktop.
NextJS API handler

Next API handler

A simple NextJS API abstraction to enforce validation and conistent HTTP responses in REST API endpoints

Features

  • Out-of-the-box validation of both the request body and search params with Zod
  • Type safe request body and search params
  • Consistent HTTP 400 on validation errors, HTTP 500 on any other runtime error
  • Same NextJS API behaviour that you already love

Quickstart

// pages/api/users

export default createHandler((create) => {
  return {
    post: create(
      {
        // Invalid body will return a HTTP 400
        body: z.object({
          firstName: z.string(),
          lastName: z.string(),
        }),
      },
      async (req, res) => {
        // Validated and type-safe body
        const { firstName, lastName } = req.body;
        const user = await createUser({ firstName, lastName });

        return res.status(200).json(user);
      }
    ),
  };
});

Migration

An example with a fictional endpoints:

  • HTTP GET request with a required sort search param
  • HTTP POST request with a required body

Before

const handler: NextApiHandler = async (req, res) => {
  if (req.method === 'POST') {
    try {
      const validationSchema = z.object({
        firstName: z.string(),
        lastName: z.string(),
      });

      let validatedBody: z.infer<typeof validationSchema>;

      try {
        validatedBody = await validationSchema.parseAsync(req.body);
      } catch (error) {
        if (error instanceof ZodError) {
          return res.status(400).json({
            code: 'VALIDATION_FAILED',
            details: error.format(),
          });
        }

        return res.status(500).json({
          code: 'INTERNAL_SERVER_ERROR',
        });
      }

      const user = await createUser(validatedBody);

      return res.status(200).json(user);
    } catch (error) {
      return res.status(500).json({
        code: 'INTERNAL_SERVER_ERROR',
      });
    }
  } else if (req.method === 'GET') {
    try {
      const validationSchema = z.object({
        sort: z.string(),
      });

      let validatedBody: z.infer<typeof validationSchema>;

      try {
        validatedBody = await validationSchema.parseAsync(req.query);
      } catch (error) {
        if (error instanceof ZodError) {
          return res.status(400).json({
            code: 'VALIDATION_FAILED',
            details: error.format(),
          });
        }

        return res.status(500).json({
          code: 'INTERNAL_SERVER_ERROR',
        });
      }

      const { sort } = req.query;
      const users = await findUsers({ sort });

      return res.status(200).json(users);
    } catch (error) {
      return res.status(500).json({
        code: 'INTERNAL_SERVER_ERROR',
      });
    }
  }
};

After

const handler = createHandler((create) => ({
  get: create(
    {
      params: z.object({
        sort: z.string(),
      }),
    },
    async (req, res) => {
      const { sort } = req.query;
      const users = await findUsers({ sort });

      return res.status(200).json(users);
    }
  ),
  post: create(
    {
      // Invalid body will return a HTTP 400
      body: z.object({
        firstName: z.string(),
        lastName: z.string(),
      }),
    },
    async (req, res) => {
      // Validated and type-safe body
      const { firstName, lastName } = req.body;
      const user = await createUser({ firstName, lastName });

      return res.status(200).json(user);
    }
  ),
}));

Source code

This handler is built upon zod

import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';
import { ZodError, type z, type ZodTypeAny } from 'zod';

const formatZodError = (error: ZodError) => {
  return error.issues.reduce(
    (errors, error) => ({
      ...errors,
      [error.path.join('/')]: error.message,
    }),
    {}
  );
};

const StatusCode = {
  VALIDATION_FAILED: 'VALIDATION_FAILED',
  INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
};

type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';

/** Next API request type extension that types the body and query types */
interface ValidatedNextApiRequest<
  RequestBody extends NextApiRequest['body'],
  RequestQuery extends NextApiRequest['query']
> extends NextApiRequest {
  body: RequestBody;
  query: RequestQuery;
}

type CreateAction = <
  BodyValidationSchema extends ZodTypeAny,
  ParamsValidationSchema extends ZodTypeAny
>(
  validationSchema:
    | Partial<{ body: BodyValidationSchema; params: ParamsValidationSchema }>
    | undefined
    | null,
  nextApiHandler: (
    req: ValidatedNextApiRequest<
      z.infer<BodyValidationSchema>,
      z.infer<ParamsValidationSchema>
    >,
    res: NextApiResponse<any>
  ) => unknown | Promise<unknown>
) => () => void | Promise<void>;

type MakeCreateHandler = (
  createAction: CreateAction
) => Partial<Record<HttpMethod, ReturnType<CreateAction>>>;

type CreateHandler = (makeCreateHandler: MakeCreateHandler) => NextApiHandler;

export const createHandler: CreateHandler = (makeCreateHandler) => {
  return async (req, res) => {
    const createAction: CreateAction =
      (validationSchema, nextApiHandler) => async () => {
        // Do validation
        try {
          const validatedBody = validationSchema?.body
            ? await validationSchema.body?.parseAsync(req.body)
            : req.body;
          // Overwrite the request body with the coerced body
          req.body = validatedBody;

          const validatedParams = validationSchema?.params
            ? await validationSchema.params?.parseAsync(req.query)
            : req.query;
          // Overwrite the request query with the coerced query
          req.query = validatedParams;
        } catch (error) {
          if (error instanceof ZodError) {
            // Validation error
            console.warn('Validation error', { error });

            return res.status(400).json({
              code: StatusCode.VALIDATION_FAILED,
              details: formatZodError(error),
            });
          }

          // Something went really wrong
          return res.status(500).json({
            code: StatusCode.INTERNAL_SERVER_ERROR,
          });
        }

        try {
          await nextApiHandler(req, res);
        } catch (error) {
          console.error('Internal server error', { error });

          return res.status(500).json({
            code: StatusCode.INTERNAL_SERVER_ERROR,
          });
        }
      };

    const actions = makeCreateHandler(createAction);
    const action = actions[`${req.method?.toLocaleLowerCase()}` as HttpMethod];
    if (!action) {
      return res.status(405).end();
    }

    await action();
  };
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment