Skip to main content

Dynamic redirects with Next.js middleware

February 22, 2025 (2d ago)

Introduction

I was recently working on a project, where we had the requirement to implement redirects on a nextjs node server, based on rules defined in a headless CMS. While Next.js provides a way to implement static redirects in the next.config.js file, this is not very flexible and does not allow for dynamic redirects.

This approach has the downside, that the redirects are not dynamic and can only be defined statically. This would require a re-deployment of the application, if the redirects should change.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async redirects() {
    return [
      // Basic redirect
      {
        source: "/about",
        destination: "/",
        permanent: true,
      },
      // Wildcard path matching
      {
        source: "/blog/:slug",
        destination: "/news/:slug",
        permanent: true,
      },
    ];
  },
};

export default nextConfig;

The solution

Nextjs offers a middleware to define custom logic based on the incoming request. It is often used to implement authentication. But we can also use it implement dynamic redirects from a headless CMS.

To implement the middleware, we need to create a new file called middleware.ts in the app folder.

import { NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  return NextResponse.next();
}

This middleware will be executed for every request, unless specified otherwise and can be used to implement dynamic redirects. The first thing we want to do is, to enable redirects based on an environment variable, to have enough flexibility to disable redirects based on teh environment (e.g. for local development).

export function middleware(request: NextRequest) {
  if (process.env.ENABLE_REDIRECTS !== "true") {
    return NextResponse.next();
  }

Next we need to fetch the redirects from the headless CMS and create the necessary types. In our example we have a simple set of redirects with source, destination and a status code.

type RedirectRule = {
  source: string;
  destination: string;
  statusCode?: number;
};

async function getRedirectRules(): Promise<RedirectRule[]> {
  try {
    const response = await fetch(
      `https://whatever-your-api-is.com/api/redirects`,
    );
    const { data } = (await response.json()) as RedirectRule[];

    return data;
  } catch (error) {
    console.error("Failed to fetch redirect rules:", error);
    return [];
  }
}

Now we can call this function and check if the request matches any of the redirects:

export async function middleware(request: NextRequest) {
  if (process.env.ENABLE_REDIRECTS !== "true") {
    return NextResponse.next();
  }

  const rules = await getRedirectRules();
  const path = request.nextUrl.pathname;

  // If no rules are found don't redirect
  if (rules.length === 0) {
    return NextResponse.next();
  }

  for (const rule of rules) {
    // Exact matches
    if (path === rule.source) {
      return NextResponse.redirect(new URL(rule.destination, request.url), {
        status: rule.statusCode ?? 301,
      });
    }
  }

  return NextResponse.next();
}

This will redirect the user to the destination url with the status code specified in the rule whenever the the source url is a perfect match. However, fetching the data on every request is not very efficient and might slow down the request quite a bit.

To improve this we should cache the data for a certain period of time with a simple in memory cache. Therefore we also need to adapt our getRedirectRules function to return a cached version of the data.

// Cache the redirect rules in memory to avoid fetching on every request
let redirectRules: RedirectRule[] = [];
let lastFetch: number = 0;
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes cache

async function getRedirectRules(): Promise<RedirectRule[]> {
  // Return cached rules if they exist and aren't stale
  if (redirectRules && Date.now() - lastFetch < CACHE_DURATION) {
    return redirectRules;
  }

  try {
    const response= await fetch(
      `https://whatever-your-api-is.com/api/redirects`,
    );
    const { data } = (await response.json()) as RedirectRule[];

    redirectRules= data;
    lastFetch= Date.now();
    return redirectRules;
  } catch (error) {
    console.error("Failed to fetch redirect rules:", error);
    return [];
  }
}

This will now cache the redirect rules for 30 minutes and only fetch the data from the headless CMS if the cache is stale. You can obviously play with this and you might want to implement a more sophisticated cache strategy. E.g. by caching the data with a redis and invalidate the cache whenever the data from the headless CMS changes, the basic set-up however will be similar.

We should also constraint the middleware to only run on necessary routes, to avoid unnecessary processing. This can be done by exporting a configuration object from the middleware.

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

This would only run the middleware on routes that are not part of the api, _next/static, _next/image or favicon.ico folder.

Conclusion

This approach allows for a flexible implementation of redirects, that can be easily extended and modified. Be careful though, that you don't create a performance bottleneck with the middleware and implement a proper form of caching the redirect rules to avoid fetching on every request.