Building an International Website With Next.js

3 solutions to toggling routes on and off for different regions

Published on
Mar 28, 2023

Read time
7 min read

Introduction

When it comes to building an international app, Next.js has a lot to offer. Its built-in support for internationalized routing — especially when combined with a popular translation library such as next-i18next — is a great place to start.

However, I hit a roadblock because not all the routes on my site were meant to be available to all regions. It seems there’s no standard, scalable approach to toggling routes on or off for different regions, and there’s not much written about this particular issue. So, in this article, I’ll share the approach my team took, as well as some of the options we passed over on our way to the result.

To be clear, this article is not about setting up internationalized routing in the first place or about translating content — both of those things are well documented in Next.js. Instead, we’ll look at solutions for more advanced internationalized routing, allowing us to toggle individual routes on or off for a given region.

The examples in the article are all written in TypeScript for a project where English (en) is the default language and where we also support Spanish (es) and German (de). The words “region” and “locale” can be different, but here they are used interchangeably.

If you’d like to see a simple implementation of the solutions discussed below, check out this example repo!

Solution 1: Client-Side Redirect

Our first option for controlling the availability of a given route might be to do so on the client, on a per-page basis, with the useRouter hook.

For example, say we wanted to deny access to a particular page for the Spanish (es ) locale, this might be an obvious starting point:

import { useRouter } from "next/router";
import { useEffect } from "react";

export default function MyPage(): JSX.Element {
  const router = useRouter();

  useEffect(() => {
    if (router.locale === "es") {
      router.push(`${router.basePath}/404`);
    }
  }, [router]);

  return <h1>Hello World</h1>;
}

This works, but it would be cumbersome to write on every page. To make things better, we could abstract our useEffect logic into a custom hook, like this:

import { useRouter } from "next/router";
import { useEffect } from "react";

export function useLocales(locales: string[]) {
  const router = useRouter();

  useEffect(() => {
    if (!locales.includes(router.locale || "en")) {
      router.push("/404");
    }
  }, [router, locales]);
}

Now, in the page component, we can call this hook and pass a list of allowed locales:

import { useLocales } from "@/hooks";

export default function MyPage() {
  useLocales(["en", "de"]);

  return <h1>Hello World</h1>;
}

This is a nice abstraction, but it still has a major pitfall: client-side redirects are higher latency than server-side ones. A significant amount of JavaScript will need to be executed before this redirect is triggered, which means users will likely notice a slight delay before the 404 appears. The page will be penalised by SEO algorithms.

Solution 2: Server-side Redirect With getServerSideProps (Pages Router Only)

So what about implementing this redirect on the server? Here’s a solution that runs inside getServerSideProps, the function Next.js gives us to build pages on the server:

import { triggerRedirectForUnavailableLocales } from "@/utils";
import { GetServerSidePropsContext } from "next";

export default function MyPage() {
  return <h1>Hello World</h1>;
}

export async function getServerSideProps(context: GetServerSidePropsContext) {
  triggerRedirectForUnavailableLocales(["en", "de"], context);
  return { props: {} };
}

Our new helper function follows similar principles to the React hook above, but — whereas the hook can only run on the client — this can only run on the server:

import { GetServerSidePropsContext } from "next";
import { join } from "path";

export function triggerRedirectForUnavailableLocales(
  allowedLocales: string[],
  context: GetServerSidePropsContext,
  redirectTo = "/404",
  isPermanent = false
) {
  const { locale, res } = context;
  const isAllowed = allowedLocales.includes(locale || "en");

  if (!isAllowed) {
    const localeSubDirectory = locale === "en" ? "/" : `/${locale}/`;
    const statusCode = isPermanent ? 308 : 307;

    res.writeHead(statusCode, {
      Location: join(localeSubDirectory, redirectTo),
    });
    res.end();
  }
}

This function is more complex than our hook, but otherwise, it is a big improvement. Because it happens on the server, the redirect runs much faster, and there is no longer a noticeable delay for the user. The page component itself requires no extra logic.

This is a reasonable approach, and we could’ve stopped here. Next.js is opinionated about routing, forcing us to define routes based on the files we store in the pages folder. Keeping the localisation code for each route in its respective file seems idiomatic in a Next.js app.

However, this was good but not perfect for the problem my team was trying to solve. We wanted to have a single view for managing routes because the stakeholders for our project had tight rules around what locales could be allowed for a given route; it would be easier for us to implement this via a single source of truth.

Solution 3: Server-side Redirect With Middleware

To achieve this single source of truth, we used middleware. This final solution requires more boilerplate than the solutions above, but as a result, we were able to combine server-side redirects with the convenience of one main place to control exactly what locales should be available for each route.

To use middleware, Next.js allows us to create a middleware.ts file. Note that, if you use a custom server.js file, there’s an important gotcha here: to use middleware, we need to provide a hostname and port to the next function in server.js:

// server.js
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = 3000;

// when using middleware `hostname` and `port` must be provided below
const app = next({ dev, hostname, port });

(This is not a complete server.js file. For that, see the official docs).

We aim to create an object with routes as its keys and an array of locales as each value. Here’s a simplified version of what we ended up with:

// routes.ts
export const staticRoutesByLocale: Record<string, string[]> = {
  "/": ["en", "es", "de"],
  "/404": ["en", "es", "de"],
  "/about": ["en", "de"],
  "/contact-us": ["en"],
};

We can make this more typesafe by creating stricter types for each route and locale, but I’ve left that out to help keep the examples as concise as possible.

This is the only object we’ll need to update as we develop our locale-specific routes. The final piece to the puzzle is our middleware.ts file, which must be added to the same directory as the pages folder (usually in the root folder or in the src folder).

This file is the largest so far, but the good news is that this can be treated as boilerplate; you only need to write it once.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { staticRoutesByLocale } from "@/routes";

const STATIC_ROUTES = Object.keys(staticRoutesByLocale);

export function middleware(request: NextRequest) {
  const pathname = request?.nextUrl?.pathname || "/";
  const locale = request?.nextUrl?.locale || "en";

  if (STATIC_ROUTES.includes(pathname)) {
    const locales = staticRoutesByLocale[pathname];
    const isLocaleSupported = locales.includes(locale) || locales.includes("*");

    if (!isLocaleSupported) {
      const localeSubDirectory = locale === "en" ? "/" : `/${locale}/`;
      const redirectPathname = localeSubDirectory + "404";

      const url = request.nextUrl.clone();
      url.pathname = redirectPathname;

      console.error(
        `Middleware Error: Locale "${locale}" is not supported for pathname "${pathname}". Rewriting to "${url}".`
      );

      return NextResponse.redirect(url);
    }
  }

  return NextResponse.next();
}

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
     * - sitemap.xml
     */
    "/((?!api|_next/static|_next/image|static|image|favicon.ico|sitemap.xml).*)",
  ],
};

To understand this file, let’s start with the config object at the bottom. This uses a regular expression pattern to filter out the request we don’t want to trigger the middleware. This mostly consists of various kinds of static files, as well as Next.js’s in-built API routes.

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

Now we know that static files won’t trigger the middleware, we have two further filtering steps. First, we check if the route exists in our staticRoutesByLocale object. Then, we check if the locale is supported. (We’re also supporting a value of "*", meaning all available locales are supported).

if (STATIC_ROUTES.includes(pathname)) {
  const locales = staticRoutesByLocale[pathname];
  const isLocaleSupported = locales.includes(locale) || locales.includes("*");

  if (!isLocaleSupported) {
    // perform redirect here
  }
}

Lastly, we can perform our redirects. For locales that aren’t supported, we can generate a new URL using request.nextUrl.clone() to get the same domain as the request, and then we can supply the pathname of the 404 page for the given locale:

const localeSubDirectory = locale === "en" ? "/" : `/${locale}/`;
const redirectPathname = localeSubDirectory + "404";

const url = request.nextUrl.clone();
url.pathname = redirectPathname;

console.error(
  `Middleware Error: Locale "${locale}" is not supported for pathname "${pathname}". Rewriting to "${url}".`
);

return NextResponse.redirect(url);

Though the middleware solution has more boilerplate code, it allows us to see all our routing decisions at a glance and keeps our individual page files as simple as possible. For these reasons, the middleware solution is the one we ended up choosing.

Bonus: Testing

Finally, as a bonus step, we can increase our confidence that only specific routes are live for a given locale with some tests. Though this is possible with any of the solutions above, it is particularly easy with the middleware solution because we already have an array of staticRoutesByLocale.

Using the popular @testing-library package, we can make sure only the expected routes are being generated for each locale:

// routes.test.ts
// routes.test.ts
import "@testing-library/jest-dom";
import { staticRoutesByLocale } from "@/routes";

const EN_ROUTES = ["/", "/404", "/about", "/contact-us"];
const ES_ROUTES = ["/", "/404"];
const DE_ROUTES = ["/", "/404", "/about"];

describe("The expected EN routes should be toggled on", () => {
  test.each(EN_ROUTES)("%s", (route) => {
    expect(staticRoutesByLocale[route]).toContain("en");
  });
});

describe("The expected ES routes should be toggled on", () => {
  test.each(ES_ROUTES)("%s", (route) => {
    expect(staticRoutesByLocale[route]).toContain("es");
  });
});

describe("The expected DE routes should be toggled on", () => {
  test.each(DE_ROUTES)("%s", (route) => {
    expect(staticRoutesByLocale[route]).toContain("de");
  });
});

// concatenate the routes above into a single array of unique routes
const ALL_ROUTES = [...new Set([...UK_ROUTES, ...ES_ROUTES, ...DE_ROUTES])];

const NON_EN_ROUTES = ALL_ROUTES.filter((route) => !EN_ROUTES.includes(route));
const NON_ES_ROUTES = ALL_ROUTES.filter((route) => !ES_ROUTES.includes(route));
const NON_DE_ROUTES = ALL_ROUTES.filter((route) => !DE_ROUTES.includes(route));

describe("Non-EN routes should not be toggled on", () => {
  test.each(NON_EN_ROUTES)("%s", (route) => {
    expect(staticRoutesByLocale[route]).not.toContain("en");
  });
});

describe("Non-ES routes should not be toggled on", () => {
  test.each(NON_ES_ROUTES)("%s", (route) => {
    expect(staticRoutesByLocale[route]).not.toContain("es");
  });
});

describe("Non-DE routes should not be toggled on", () => {
  test.each(NON_DE_ROUTES)("%s", (route) => {
    expect(staticRoutesByLocale[route]).not.toContain("de");
  });
});

I hope this article has been useful for anyone working on internationalized routing in Next.js. If you’d like to see a simple, working version of the solutions discussed above, check out this example repo.

© 2024 Bret Cameron