DEV Community

Cover image for Next.js API routes - Global Error Handling and Clean Code Practices
Snehil
Snehil

Posted on

Next.js API routes - Global Error Handling and Clean Code Practices

Next.js is one of the leading React-based frameworks out there. With almost no learning curve on top of React.js and the fact that it provides great SEO out of the box, it has become a popular choice for many developers.

It uses file-system-based routing and also provides a flexible way to create API routes which is a great way to create serverless functions in Next.js with simplicity.

But, with simplicity and flexibility come the following issues:

  • Redundant code for request method verification and validation.
  • The official documentation suggests using lengthy if-else-if chains to handle multiple request methods.
  • No conventions for handling API errors. This is a problem because we want to handle errors in a consistent way (so that we can catch them on the frontend) and also provide a way to log unexpected errors.

So to address these issues, I came up with a Higher Order Function (HOF) that abstracts away all the redundant code and error-handling so you can focus on your core business logic. All you need to worry about is when to return a response and when to throw an error. The error response will automatically be created by the Global Error Handler (Central Error Handling).

Central Error Handling GIF

CREDITS: This method was inpired from https://jasonwatmore.com/post/2021/08/23/next-js-api-global-error-handler-example-tutorial

So without ado, let's dive in.

Chapters

  1. Project Setup
  2. API Handler Higher Order Function
  3. Global Error Handler
  4. Creating example API route
  5. Wrapping up
  6. Source Code

SIDENOTE: This is my first post on DEV so suggestions are not just welcome, they are mandatory! :P


1. Project Setup

Start by creating a Next.js project using the create-next-app npm script. Notice that I'm using the --ts flag to initialize with TypeScript because it's 2022 folks, make the switch!

npx create-next-app@latest --ts
# or
yarn create next-app --ts
Enter fullscreen mode Exit fullscreen mode

We'll need to install two additional packages; one for schema validation (I prefer yup) and another for declaratively throwing errors, http-errors.

npm i yup http-errors && npm i --dev @types/http-errors
# or
yarn add yup http-errors && yarn add --dev @types/http-errors
Enter fullscreen mode Exit fullscreen mode

2. API Handler Higher Order Function

Create a new file ./utils/api.ts. This file exports the apiHandler() function which acts as the entry point for any API route. It accepts a JSON mapping of common HTTP request methods and methodHandler() functions. It returns an async function that wraps all the API logic into a try-catch block which passes all the errors to errorHandler(). More on that later.

// ./utils/api.ts
import createHttpError from "http-errors";

import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { Method } from "axios";

// Shape of the response when an error is thrown
interface ErrorResponse {
  error: {
    message: string;
    err?: any; // Sent for unhandled errors reulting in 500
  };
  status?: number; // Sent for unhandled errors reulting in 500
}

type ApiMethodHandlers = {
  [key in Uppercase<Method>]?: NextApiHandler;
};

export function apiHandler(handler: ApiMethodHandlers) {
  return async (req: NextApiRequest, res: NextApiResponse<ErrorResponse>) => {
    try {
      const method = req.method
        ? (req.method.toUpperCase() as keyof ApiMethodHandlers)
        : undefined;

      // check if handler supports current HTTP method
      if (!method)
        throw new createHttpError.MethodNotAllowed(
          `No method specified on path ${req.url}!`
        );

      const methodHandler = handler[method];
      if (!methodHandler)
        throw new createHttpError.MethodNotAllowed(
          `Method ${req.method} Not Allowed on path ${req.url}!`
        );

      // call method handler
      await methodHandler(req, res);
    } catch (err) {
      // global error handler
      errorHandler(err, res);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

If a request is received for an unsupported request.method, we throw a 405 (Method Not Allowed) Error. Notice how we're throwing the error using http-errors. This is how we'll handle any expected errors in our business logic. It enforces a convention that all devs must follow on the project which produces consistent error responses.

NOTE: Inside ApiMethodHandlers, I'm using the Method type from axios. If you don't want to install axios, you can define it somewhere as,

export type Method =
  |'GET'
  |'DELETE'
  |'HEAD'
  |'OPTIONS'
  |'POST'
  |'PUT'
  |'PATCH'
  |'PURGE'
  |'LINK'
  |'UNLINK';
Enter fullscreen mode Exit fullscreen mode

3. Global Error Handler

When an error is thrown anywhere in our APIs, it'll be caught by the top level try-catch block defined in our apiHandler() (unless ofcourse we define another error boundary somewhere deeper). The errorHandler() checks for the type of error and responds accordingly.

// ./utils/api.ts

import { ValidationError } from "yup";



function errorHandler(err: unknown, res: NextApiResponse<ErrorResponse>) {
  // Errors with statusCode >= 500 are should not be exposed
  if (createHttpError.isHttpError(err) && err.expose) {
    // Handle all errors thrown by http-errors module
    return res.status(err.statusCode).json({ error: { message: err.message } });
  } else if (err instanceof ValidationError) {
    // Handle yup validation errors
    return res.status(400).json({ error: { message: err.errors.join(", ") } });
  } else {
    // default to 500 server error
    console.error(err);
    return res.status(500).json({
      error: { message: "Internal Server Error", err: err },
      status: createHttpError.isHttpError(err) ? err.statusCode : 500,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

All unforeseen errors are considered unhandled and are presented as 500 Internal Server Errors to the users. I also chose to send the stack trace and log it in this case for debugging.


4. Creating example API route

With that, our apiHanlder() is complete and can now be used instead of a plain old function export inside all API route files.
Let's create a demo route to see it in action.
I'll show this by creating a fake blog REST api /api/article?{id: string}. Create a file under ./pages/api/ and name it article.ts.

// ./pages/api/article.ts
import createHttpError from "http-errors";
import * as Yup from "yup";

import { NextApiHandler } from "next";

import { apiHandler } from "utils/api";
import { validateRequest } from "utils/yup";

// Fake DB to demonstrate the API
const BLOG_DB = [
  {
    id: 1,
    title: "Top 10 anime betrayals",
    content: "Lorem ipsum dolor sit amet ....",
    publishedTimestamp: 1665821111000,
  },
];

type GetResponse = {
  data: typeof BLOG_DB | typeof BLOG_DB[number];
};

/**
 * returns all articles or the article with the given id if query string is provided
 */
const getArticle: NextApiHandler<GetResponse> = async (req, res) => {
  const { id } = req.query;
  if (id) {
    // find and return article with given id
    const article = BLOG_DB.find((article) => article.id === Number(id));

    if (!article)
      throw new createHttpError.NotFound(`Article with id ${id} not found!`);
    // OR
    // if (!article) throw new createHttpError[404](`Article with id ${id} not found!`)
    res.status(200).json({ data: article });
  } else {
    res.status(200).json({ data: BLOG_DB });
  }
};

type PostResponse = {
  data: typeof BLOG_DB[number];
  message: string;
};

const postArticleSchema = Yup.object().shape({
  title: Yup.string().required("Title is required!"),
  content: Yup.string()
    .required("Content is required!")
    .max(
      5000,
      ({ max }) => `Character limit exceeded! Max ${max} characters allowed!`
    ),
  publishedTimestamp: Yup.number()
    .required("Published timestamp is required!")
    .lessThan(Date.now(), "Published timestamp cannot be in the future!"),
});

const createArticle: NextApiHandler<PostResponse> = async (req, res) => {
  const data = validateRequest(req.body, postArticleSchema);
  const newArticle = { ...data, id: BLOG_DB.length + 1 };
  BLOG_DB.push(newArticle);

  res
    .status(201)
    .json({ data: newArticle, message: "Article created successfully!" });
};

type DeleteResponse = {
  data: typeof BLOG_DB[number];
  message: string;
};

const deleteArticleById: NextApiHandler<DeleteResponse> = async (req, res) => {
  const { id } = req.query;

  if (!id) throw new createHttpError.BadRequest("Article id is required!");

  const articleIndex = BLOG_DB.findIndex(
    (article) => article.id === Number(id)
  );

  if (articleIndex < 0)
    throw new createHttpError.NotFound(`Article with id ${id} not found!`);

  BLOG_DB.splice(articleIndex, 1);

  res.status(200).json({
    data: BLOG_DB[articleIndex],
    message: "Article deleted successfully!",
  });
};

export default apiHandler({
  GET: getArticle,
  POST: createArticle,
  DELETE: deleteArticleById,
});
Enter fullscreen mode Exit fullscreen mode

The validateRequest() function is a helper which takes in a yup schema and validates the JSON. It also returns the validated data with appropriate types. Yup throws a ValidationError if this validation fails which we're handling in our errorHandler().

// ./utils/yup.ts

import { ObjectSchema } from "yup";
import { ObjectShape } from "yup/lib/object";

export function validateRequest<T extends ObjectShape>(
  data: unknown,
  schema: ObjectSchema<T>
) {
  const _data = schema.validateSync(data, {
    abortEarly: false,
    strict: true,
  });
  return _data;
}
Enter fullscreen mode Exit fullscreen mode

You can test the API we just created using Postman or any other tool of your choice.
Run in Postman


5. Wrapping up

Notice how clean and descriptive our business logic looks โœจ

โœ… res.json() is called only when we need to send a success response.
โœ… In all other cases we throw the appropriate error contructed by http-errors and leave the rest on the parent function.
โœ… Controllers are divided into functions and plugged into the API Route by their respective req.method which is kind of reminiscent of how Express.js routes are defined.

Enforcing clean code practices in our projects increases code readability which matters a lot as the scale of the project starts increasing. ๐Ÿ’ช๐Ÿฝ

I don't claim that this is the best way to achieve this goal so if you know of any other better ways, do share! ๐Ÿ™Œ๐Ÿฝ

I hope you liked this post and it'll help you in your Next.js projects. If it helped you with a project, I'd love to read about it in the comments. ๐Ÿ’œ

Shameless Plug
Checkout my recently published Modal library for React.js :3
react-lean-modal


6. Source Code

GitHub logo SneakySensei / Next-API-Handler-Example

Accompanying example project for my post on Dev.to

Create API Routes using apiHandler higher order function for consistency and clean code ๐Ÿ”ฅ

Features โšก

  • res.json() is called only when we need to send a success response.
  • Error responses are handled by apiHandler when we throw an error.
  • Controllers are divided into functions and plugged into the API Route by their respective req.method which is kind of reminiscent of how Express.js routes are defined.

Technologies ๐Ÿงช

Installation ๐Ÿ“ฆ

First, run the development server:

```bash
npm run dev
# or
yarn dev
Enter fullscreen mode Exit fullscreen mode

Try it out! ๐Ÿš€

Run in Postman

Checkout the related Post on Dev.to ๐Ÿ“–

https://dev.to/sneakysensei/nextjs-api-routes-global-error-handling-and-clean-code-practices-3g9p

Top comments (0)