Eric
Howey.

Advanced internationalization with Next.js and middleware

Heads up that NextJs v12.2 included a stable release of middleware, most of what you see here should work but might require some adaption depending on your implementation. Read their upgrade guide for more details.

Next.js 12 introduced middleware for handling logic at the CDN level (sometimes called an edge function) before a page loads. This is incredibly powerful and unlocks the potential for static pages to have limited server-side logic. There are lots of examples of this but one compelling use case I recently used it for is advanced internationalization and content localization.

Project Demo

GitHub Repo

Be forewarned that code samples in this article are rather large, it may be easier to just browse the GitHub repo linked above.

User story

Before we dive into the code let’s take a step back and talk about the user experience we want to achieve. Imagine we have an international e-commerce store based in both North America and Europe; this means we also need to worry about GDPR and cookie legislation as well. On the surface this may seem simple enough but it is worth breaking the logic down.

We are going to support the following locales, you should be able to easily add more or less based on the pattern and code here.

  • en-gb, English-Great Britain
  • en-ca, English-Canada
  • fr-fr, French-France
  • fr-be, French-Belgium
  • fr-ca, French-Canada
  • fr, French language fallback
  • en, English as the final default locale fallback

Let’s say we have a new user visiting the website from France for the first time:

  • They should be redirected to www.mysite.com/fr-fr/
  • They should be shown a cookie banner

Now let’s say this same user is curious about prices in the UK and visits www.mysite.com/en-us/store

  • They should still be able to view this address without being redirected back to the France website
  • This shouldn’t affect the locale they are shown on a return visit

Next this user realizes that they are on the website for France, but they actually live in Belgium and want to see content relevant to their own country.

  • They should be able to change their locale to fr-be
  • If they have accepted the cookie notice we should also set a cookie so that this locale preference is saved for any return visits. Now if they come back to the site they will always be shown the French-Belgium version, instead of the French-France version.

This same user, being security concious now starts using a VPN and is redirecting their locale through the United States. They are also browsing in private mode (any previous cookies are ignored). However, their operating system language is set to French.

  • They should be shown the fr fallback

Now imagine an entirely different user living abroad in Japan visits the site. We don’t support their country or language and there are no previous cookies set.

  • They should be shown the en fallback

Finally if a user has set a cookie for their language preference we should always respect this choice.

Whew! See it starts to get a bit tricky as you think through the various edge cases that can come up beyond just detecting a country and language.

Next.js Config

Next.js has an i18n config option which can be set in the next.config.js file. The two things to note here is that we set a locale of default which we also specify as the defaultLocale and that we set localeDetection: false. We turn off Next.js locale detection because we are going to handle that ourselves in the middleware.

/** @type {import('next').NextConfig} */
module.exports = {
  reactStrictMode: true,
  i18n: {
    // These are all the locales you want to support in
    // your application
    locales: [
      'default',
      'en',
      'fr',
      'en-gb',
      'fr-fr',
      'fr-be',
      'en-ca',
      'fr-ca',
    ],
    // This is the default locale you want to be used when visiting
    // a non-locale prefixed path e.g. `/hello`
    defaultLocale: 'default',
    localeDetection: false,
  },
}

Middleware code

Now we get into the fun bits! Here is what that user experience we described looks like in code, the _middleware.js file should be in the root of your /pages directory. Note that we return early for public files, like images and we return early for api routes.

// pages/_middleware.js
import { NextResponse } from 'next/server'

// Regex to check whether something has an extension, e.g. .jpg
const PUBLIC_FILE = /\.(.*)$/

// Next JS Middleware
export const middleware = (request) => {
  // Get the information we need from the request object
  const { nextUrl, geo, headers, cookies } = request
  // Cloned url to work with
  const url = nextUrl.clone()
  // Client country, defaults to us
  const country = geo?.country?.toLowerCase() || 'us'
  // Client language, defaults to en
  const language =
    headers
      .get('accept-language')
      ?.split(',')?.[0]
      .split('-')?.[0]
      .toLowerCase() || 'en'

  // // Helpful console.log for debugging
  // console.log({
  //   nextLocale: nextUrl.locale,
  //   pathname: nextUrl.pathname,
  //   cookie: cookies.NEXT_LOCALE,
  //   clientCountry: country,
  //   clientLanguage: language,
  // });

  try {
    // Early return if it is a public file such as an image
    if (PUBLIC_FILE.test(nextUrl.pathname)) {
      return undefined
    }
    // Early return if this is an api route
    if (nextUrl.pathname.includes('/api')) {
      return undefined
    }

    // Early return if we are on a locale other than default
    if (nextUrl.locale !== 'default') {
      return undefined
    }

    // Early return if there is a cookie present and on default locale
    if (cookies.NEXT_LOCALE && nextUrl.locale === 'default') {
      url.pathname = `/${cookies.NEXT_LOCALE}${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // We now know:
    // No cookie that we need to deal with
    // User has to be on default locale

    // Redirect All France
    if (country === 'fr') {
      url.pathname = `/fr-fr${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // Redirect All Belgium
    if (country === 'be') {
      url.pathname = `/fr-be${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // Redirect all Great Britain
    if (country === 'gb') {
      url.pathname = `/en-gb${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // Redirect French-Canada
    if (country === 'ca' && language === 'fr') {
      url.pathname = `/fr-ca${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // Redirect all other Canadian requests
    if (country === 'ca') {
      url.pathname = `/en-ca${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // Handle French language fallback
    if (language === 'fr') {
      url.pathname = `/fr${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // Handle the default locale fallback to english
    if (nextUrl.locale === 'default') {
      url.pathname = `/en${nextUrl.pathname}`
      return NextResponse.redirect(url)
    }

    // If everything else falls through continue on with response as normal
    return undefined
  } catch (error) {
    console.log(error)
  }
}

Language selector and cookies

Next up we need a language selector so the user can change their language. In the project demo I am using Radix-UI and react-cookie-consent to help abstract some accessibility concerns and actually setting the cookie consent, but you don’t need to use these packages.

import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
// RadixUI is used here but isn't necessary
import * as Popover from '@radix-ui/react-popover'

// You would likley have this is a seperate file, but need a list of
// supported languages to map over.
const languages = [
  {
    locale: 'en',
    name: 'English',
  },
  {
    locale: 'fr',
    name: 'French',
  },
  {
    locale: 'en-gb',
    name: 'English-Great Britain',
  },
  {
    locale: 'en-ca',
    name: 'English-Canada',
  },
  {
    locale: 'fr-fr',
    name: 'Francais-France',
  },
  {
    locale: 'fr-ca',
    name: 'Francais-Canada',
  },
  {
    locale: 'fr-be',
    name: 'Francais-Belge',
  },
]

const LanguageSelect = () => {
  // Get the info we need from the Next.js router
  const router = useRouter()
  const { pathname, asPath, query, locale = 'en' } = router

  // State for the currently selected locale
  const [selectedLang, setSelectedLang] = useState(locale)

  // State for whether the popover is open or not
  const [isPopoverOpen, setIsPopoverOpen] = useState(false)

  // Handle button click
  const handleClick = (languageLocale) => {
    setSelectedLang(languageLocale)
    setIsPopoverOpen(false)
  }

  // Update the router and locale if the selected language is changed
  useEffect(() => {
    // Get the full cookie consent
    const cookieConsent = document.cookie
      ? document.cookie
          .split('; ')
          .find((row) => row.startsWith('hasCookieConsent='))
      : null

    // Get the value of the cookie, note this will be a string
    const cookieConsentString = cookieConsent
      ? cookieConsent.split('=')[1]
      : false

    // Extract the value to a boolean we can use more easily
    const hasCookieConsent = cookieConsentString === 'true'

    if (selectedLang === 'en') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=en; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'en' })
    }
    if (selectedLang === 'fr') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=fr; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'fr' })
    }
    if (selectedLang === 'fr-ca') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=fr-ca; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'fr-ca' })
    }
    if (selectedLang === 'fr-fr') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=fr-fr; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'fr-fr' })
    }
    if (selectedLang === 'fr-be') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=fr-be; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'fr-be' })
    }
    if (selectedLang === 'en-ca') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=en-ca; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'en-ca' })
    }
    if (selectedLang === 'en-gb') {
      // If we have consent set a cookie
      if (hasCookieConsent) {
        document.cookie = `NEXT_LOCALE=en-gb; maxage=${
          1000 * 60 * 60 * 24 * 7
        }; path=/`
      }
      router.push({ pathname, query }, asPath, { locale: 'en-gb' })
    }
  }, [selectedLang]) //eslint-disable-line

  return (
    <Popover.Root open={isPopoverOpen}>
      <Popover.Trigger onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
        Change language &rarr;
      </Popover.Trigger>
      <Popover.Content style={{ backgroundColor: '#ccc' }}>
        <Popover.Arrow />
        <Popover.Close onClick={() => setIsPopoverOpen(!isPopoverOpen)}>
          Close
        </Popover.Close>
        <div
          style={{
            padding: '1rem',
            display: 'grid',
            gridTemplateColumns: '130px 130px',
            gridAutoFlow: 'dense',
            gap: '0.5rem',
          }}
        >
          {languages.map((language) => {
            const isActive = language.locale === locale
            return (
              <button onClick={() => handleClick(language.locale)}>
                {language.name}
                {isActive && <span>&#10003;</span>}
              </button>
            )
          })}
        </div>
      </Popover.Content>
    </Popover.Root>
  )
}

export default LanguageSelect

Next steps

Hopefully this gets you started in your journey with Next.js and internationalization. There are lots of complexities to consider and Next.js has an article on the topic that covers a few other topic areas.

Happy coding