How to: Vercel Edge Middleware with 19,000 redirects

Introduction

After several attempts to find a suitable example of a Vercel Edge Middleware function that handles redirects, I found myself empty handed, a little bit frustrated and a tiny bit motivated. Motivated to create my own solution, and write this article about doing so - as it's been about 2 and a bit months since my last written piece 🥲.

Fortunately, I was successful... in writing the function that is - the degree of success to which this article is received is entirely in your hands.

HOWEVER - After being given 19,203 redirects by a client, I ended up going a bit further than I originally had planned as I could just imagine my middleware.ts file intercepting every single request going to the clients website and it made me get the spookies. This article will walk you through my journey of creating a performant redirect Edge Middleware for a Next.js website hosted on Vercel.

Before we start

Edge Middleware is code that executes before a request is processed by a server. By operating prior to the cache, Middleware emerges as an effective means of intercepting the planned or default behaviour of a request and its response contents. This enables developers to execute custom logic, append headers to responses, perform A/B testing and undertake various other actions such as, performing redirects - which is the focal point of this piece.

The backbone of Middleware lies in its utilization of the Edge Runtime, which exposes a carefully curated subset of Web Standard APIs. These APIs, including FetchEvent, Response, and Request, enable Middleware to perform its magic. I highly recommend exploring the official Vercel documentation on Edge Middleware, here if you like this post.

Vercel Middleware diagram

Image sourced from Next.js documentation.

Route matching and redirection

When a user navigates to a web page or makes an API request, the browser or client sends an HTTP request to the server with information about the requested URL, method, headers, and body. The server then processes the request and returns a response with the requested content, along with any additional metadata such as status codes, cookies, and headers.

A pinch of context

The client that I was working with was migrating from Commerce Vision to Headless Shopify Plus. Working with an SEO agency, the client put together a reasonably large redirects array that had to be uploaded to the server of the new website, to ensure there was no loss in SEO ranking, as well as ensure any old links to the clients website, did not go to a 404 - Not found page.

The redirects array follows the recommended structure as per the Vercel documentation, and it looked like the following:

[
  ...
  {
    "source": "/old-product-page-handle",
    "destination": "/products/new-product-page-handle",
    "permanent": true
  },
  ...
]

If you didn't already know, the next.config.js file has a limitation of 1,024 redirects so using this method was not a viable solution.

Solution 1: Edge Middleware with an array of redirects

What I needed to do was look at the initial request sent to the server and check if that specific request path could be found inside a specific redirect source in the routes file. If there was a redirect source that matched the exact path of the request path, we redirect the request to the destination path associated with the redirect source.

Here's an example of a basic Edge Middleware function on Vercel that performs route matching and redirection based on a set of predefined routes:

import { NextRequest, NextResponse } from 'next/server'
import routes from './routes'

type Route = {
  source: string,
  destination: string,
  permanent: boolean,
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl

  const matchingRoute = routes.find((route: Route) => route.source === pathname)

  if (matchingRoute && matchingRoute.destination) {
    console.log(`redirecting to: ${matchingRoute.destination}`)
    return NextResponse.redirect(matchingRoute.destination, {
      status: matchingRoute.permanent ? 301 : 302,
    })
  } else {
    return NextResponse.next()
  }
}

In this example, the Edge Middleware takes an incoming request object req and extracts the pathname property from nextUrl. It then searches the routes array for a matching route with a source property that matches the variable pathname.

In JavaScript, the .find() method is used to search for the first element in an array that satisfies a given condition. It returns the matching element or undefined if no match is found. It simplifies the process of finding specific items within an array.

If a matching route is found, the Edge Middleware logs a message indicating the redirection and returns a NextResponse.redirect object with the destination property of the matching route, along with a status code of 301 (permanent) or 302 (temporary) depending on the value of the permanent property of the matching route.

If no matching route is found, the Edge Middleware simply returns a NextResponse.next object to allow the request to continue without manipulation to the next middleware in the chain.

The above implementation works perfectly well and you can copy this into your project as is. In the next section, we'll explore how using a hashtable can improve the performance of the same Edge Middleware function.

Solution 2: Using a hashtable for faster route matching

A hashtable is a data structure that efficiently stores key-value pairs using a hash function. In JavaScript, a hashtable is implemented using an object, where keys are mapped to values directly. Meaning it allows fast access and retrieval of values based on keys.

In the context of my Edge Middleware function on Vercel, using a hashtable to store the routes can improve the performance of the middleware by reducing the time complexity of the route matching operation from O(n) to O(1).

Using my degree proudly here:

Moving from O(n) to O(1) complexity is like upgrading from a situation where the time it takes to do a task increases linearly with the amount of work, to a situation where the time it takes to do a task stays the same. No matter how much work there is.

This is because with a hashtable, we can look up the matching route directly by its source property without having to search through the entire array of routes.

Here's an updated implementation of the Edge Middleware function that uses a hash table to store the routes:

import { NextRequest, NextResponse } from 'next/server'
import routes from './routes'

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const ROUTES: { [key: string]: string } = routes
  const route = ROUTES[pathname]

  if (!route) {
    return NextResponse.next()
  }

  console.log(`redirecting to: ${process.env.NEXT_PUBLIC_SITE_URL}${route}`)
  return NextResponse.redirect(`${process.env.NEXT_PUBLIC_SITE_URL}${route}`, {
    status: 301,
  })
}

An example entry inside that hashtable:

  {
    ...
    "/old-product-page-handle": "/products/new-product-page-handle",
    ...
  }

So how does it work?

The ROUTES hashtable is being accessed using the pathname variable as the key. It attempts to retrieve the corresponding value (route) associated with the given pathname. If a matching route is found, it will be stored in the route variable. Otherwise, if no match is found, the route variable will be undefined.

If there is route, NextResponse.redirect() will send the incoming request to the correct destination based on the value of the route found by its key in the hashtable. Otherwise, the request will continue onwards to the original desired as normal.

But wait, theres more!

If you deployed either of the above examples to Vercel, you would have noticed that the Edge Middleware would be firing on every single request that the server is getting, including images, fonts and other undesired paths. To optimise the solution further so that the Edge Middleware only runs on desired routes, you can update your function to include the following:

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - fonts/* (any font urls)
     * - pages/* (any EXPECTED page routes)
     * - products/* (any EXPECTED product routes)
     * - collections/* (any EXPECTED collection routes)
     * - blogs/* (any EXPECTED blog routes)
     */
    '/((?!api|_next/static|_next/image|favicon.ico|fonts/*|icons/*|pages/*|products/*|collections/*|blogs/*).*)',
  ],
}

The additional code above uses Regex to ignore specific request paths, such as images, fonts, api routes and more. The final function looks like:

import { NextRequest, NextResponse } from 'next/server'
import routes from './routes'

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const ROUTES: { [key: string]: string } = routes
  const route = ROUTES[pathname]

  if (!route) {
    return NextResponse.next()
  }

  console.log(`redirecting to: ${process.env.NEXT_PUBLIC_SITE_URL}${route}`)
  return NextResponse.redirect(`${process.env.NEXT_PUBLIC_SITE_URL}${route}`, {
    status: 301,
  })
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - fonts/* (any font urls)
     * - pages/* (any EXPECTED page routes)
     * - products/* (any EXPECTED product routes)
     * - collections/* (any EXPECTED collection routes)
     * - blogs/* (any EXPECTED blog routes)
     */
    '/((?!api|_next/static|_next/image|favicon.ico|fonts/*|icons/*|pages/*|products/*|collections/*|blogs/*).*)',
  ],
}

Conclusion

Now you have an Edge Middleware function that can work with thousands of redirects performantly using a hashtable. I hope you found this article helpful and insightful - I enjoyed writing it. Please feel free to take the code and use it in your projects! If you have any questions or comments, feel free to ask.