skies.dev

High-Order Functions for Reusable Middleware in Next.js

6 min read

Starting in version 12, Next.js introduced first-class support for middleware.

In this article, however, we'll look at how to create middleware using JavaScript high-order functions.

This approach could still be useful if you're

  • not deploying to Vercel
  • using Next.js 11 or an earlier version
  • trying to learn another approach to writing middleware

We'll use a learn-by-example approach. We'll create a middleware that logs metrics, handles authentication, and validates the request body. I'll leave some exercise problems if you want some practice.

Let's get started.

Metrics

The first example I'll show is the simplest. I want to create a middleware that logs information about the HTTP request.

To create the higher-order function, we need to create a function that:

  1. takes as argument a handler function
  2. runs some business logic
  3. executes the handler function before returning
lib/middleware/with-withMetrics.ts
import {NextApiHandler, NextApiRequest, NextApiResponse} from 'next'

const withMetrics = (handler: NextApiHandler) => {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    console.info('[api]', req.method, req.url)
    await handler(req, res)
  }
}

export default withMetrics

To see the middleware in action, we'll apply it to a simple API route: One that always sends a rocket emoji.

pages/api/rocket.js
import { NextApiRequest, NextApiResponse } from 'next'
import withMetrics from '@lib/middleware/with-withMetrics.ts'

const handler = (req: NextApiRequest, res: NextApiResponse) => {
  return res.status(200).send('🚀')
}

export default withMetrics(handler)

Now when we hit this API route, we will see the information about the request logged to the the server console.

Authentication

Another practical use case for middleware is authentication.

The topic of authentication goes deep and beyond the scope of this article. We'll create a naive authentication implementation for demonstration. Our implementation will check that the x-server-secret header sent by the client matches the server secret stored as an environment variable.

lib/middleware/with-auth.ts
import {NextApiHandler, NextApiRequest, NextApiResponse} from 'next'

const withAuth = (handler: NextApiHandler) => {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    // Reject unauthorized clients
    if (req.headers['x-server-secret'] !== process.env.SERVER_SECRET) {
      res.status(401).send('You did provide the secret value!')
      return
    }

    await handler(req, res)
  }
}

export default withAuth

It goes without saying you should follow a more secure practice when adding authentication to your own app.

We can now update /pages/api/rockets.ts to only accept requests from clients that send the correct x-server-secret header value.

pages/api/rocket.js
import { NextApiRequest, NextApiResponse } from 'next'
import withMetrics from '@lib/middleware/with-withMetrics.ts'
import withAuth from '@lib/middleware/with-auth.ts'

const handler = (req: NextApiRequest, res: NextApiResponse) => {
  return res.status(200).send('🚀')
}

export default withMetrics(withAuth(handler))

If this looks confusing to you—functions passing in functions passing in functions— I was confused when I was learning it too. Try this. Read each higher-order function from left to right:

First, withMetrics is run (the HTTP request info is logged). Then, authentication (withAuth) is run. If the client is authenticated, a 200 (OK) is sent with a rocket emoji. Otherwise, a 401 (Unauthorized) is sent.

Validation

The third and final example we'll look at is a middleware for validating the request body in a POST request. For schema validation, I like Yup, so we'll be using it along with Lodash in the implementation, inspired by Bruno Antunes' Yup validation middleware.

npm install yup lodash
npm install -D @types/yup

We'll write a function withBody that takes two arguments:

  1. schemas: Yup validation schemas.
  2. handler: The same Next.js API handler we've come to love.
lib/middleware/with-body.ts
import {NextApiHandler, NextApiRequest, NextApiResponse} from 'next'
import {ObjectShape} from 'yup/lib/object'
import {has} from 'lodash'
import {ObjectSchema} from 'yup'

export type IHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'

export type IValidationSchemas = Partial<
  Record<IHttpMethod, ObjectSchema<ObjectShape>>
>

export function withBody(
  schemas: IValidationSchemas,
  handler: NextApiHandler,
) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    if (!has(schemas, req.method)) {
      await handler(req, res)
      return
    }

    const schema = schemas[req.method]

    if (schema == null) {
      const message = `Requires non-null validation schema to validate request body`
      console.warn(message)
      res.status(400).send(message)
      return
    }

    try {
      req.body = await schema.validate(req.body, {
        abortEarly: process.env.NODE_ENV !== 'production',
      })
      await handler(req, res)
    } catch (err) {
      const message = `Failed to validate request body: ${err.message}`
      console.warn(message)
      res.status(400).send(message)
    }
  }
}

Now we'll go back to our API route and allow clients to create new rockets, modeled by the IRocket interface.

pages/api/rocket.ts
interface IRocket {
  id: number
  name: string
  org: string
}

We'll use Yup to define our validation schemas.

pages/api/rocket.ts
const rocketValidationSchemas: IValidationSchemas = {
  POST: yup.object().shape({
    name: yup.string().required(),
    org: yup.string().required(),
  })
}

We will model the database with an array.

In your app, you'll want a database that is

pages/api/rocket.ts
const rockets: IRocket[] = [
  {
    id: 0,
    name: 'Falcon V',
    org: 'Space X',
  },
  {
    id: 1,
    name: 'New Glenn',
    org: 'Blue Origin',
  },
  // And so on...
]

With these pieces, we'll add support for adding a rocket.

pages/api/rocket.ts
const handler = (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'POST') {
    const newRocket = {
      id: rockets.length,
      ...req.body,
    }
    rockets.push(newRocket)
    return res.status(201).json(newRocket)
  }

  return res.status(200).send('🚀')
}

export default withMetrics(withAuth(withBody(rocketValidationSchemas, handler)))

Consider the execution of this API for POST requests:

  1. Log info about the request
  2. Authenticate the client
  3. If authenticated, validate the request body
  4. If body is valid, push the new rocket to the array

You've now looked at three examples of using higher-order functions for middleware in Next.js apps.

If you ever find yourself rewriting the same code in your API routes, think about how you could reuse code in a middleware.

Exercises

Here are some exercises to practice writing middleware. Each exercise harder than the previous.

  1. Create a middleware withDevOnly that sends a 403 (Forbidden) whenever running in production. Hint: check process.env.NODE_ENV === 'development'.
  2. Create a middleware withMethods that takes IHttpMethod[] and sends 405 (Method Not Allowed) if the request method is not included.
  3. Create a middleware withHandlers that takes a Partial<Record<IHttpMethod, NextApiHandler>> to supply implementations for each IHttpMethod. If the request method does not have an implementation, send 405 (Method Not Allowed).

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like