Next.js API routes - Global Error Handling and Clean Code Practices
Tags: nextjs, serverless, typescript, javascript
Published: 2022-10-15 12:53:22

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/97jbzoxq3n2bvucnes6i.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](#step-1)
2. [API Handler Higher Order Function](#step-2)
3. [Global Error Handler](#step-3)
4. [Creating example API route](#step-4)
5. [Wrapping up](#step-5)
6. [Source Code](#step-6)

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

---

## 1. Project Setup <a name="step-1"></a>

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!

```bash
npx create-next-app@latest --ts
# or
yarn create next-app --ts
```

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

```bash
npm i yup http-errors && npm i --dev @types/http-errors
# or
yarn add yup http-errors && yarn add --dev @types/http-errors
```

---

## 2. API Handler Higher Order Function <a name="step-2"></a>

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.

```typescript
// ./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);
    }
  };
}
```

If a request is received for an unsupported `request.method`, we throw a [405 (Method Not Allowed) Error](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/405). 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,

```typescript
export type Method =
  |'GET'
  |'DELETE'
  |'HEAD'
  |'OPTIONS'
  |'POST'
  |'PUT'
  |'PATCH'
  |'PURGE'
  |'LINK'
  |'UNLINK';
```

---

## 3. Global Error Handler <a name="step-3"></a>
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.

```typescript
// ./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,
    });
  }
}
```

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

---

## 4. Creating example API route <a name="step-4"></a>
With that, our `apiHanlder()` is complete and can now be used instead of a [plain old function export](https://nextjs.org/docs/api-routes/introduction) 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`.

```typescript
// ./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,
});
```

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()`.

```typescript
// ./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;
}
```

You can test the API we just created using Postman or any other tool of your choice.
[![Run in Postman](https://run.pstmn.io/button.svg)](https://god.gw.postman.com/run-collection/16832202-de3b3f8e-f82b-403f-9530-836f4c5adc22?action=collection%2Ffork&collection-url=entityId%3D16832202-de3b3f8e-f82b-403f-9530-836f4c5adc22%26entityType%3Dcollection%26workspaceId%3D73ada041-1978-4536-b11c-a3bd606bd1b0)

---

## 5. Wrapping up <a name="step-5"></a>

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`](url)

---

## 6. Source Code <a name="step-6"></a>
{% embed https://github.com/SneakySensei/Next-API-Handler-Example.git %}