Eric
Howey.

There is a hidden page you can visit at /you-are-awesome. Didn't want you to miss out on the fun!
Content has not been updated for more than 2 years, proceed with caution.

Updated

Advanced internationalization with Next.js and middleware

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.

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.

next-config.js
/** @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 above looks like in code. Note that we return early for public files, like images and we return early for api routes.

Your middleware.ts file should be located inside of the root project directory or inside the src folder if you are using it. During the beta release the middleware file was located inside of the pages folder and also had an _ in front of it. Double check to make sure you have the middleware file in the right location and with the right name!

middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
// Regex to check whether something has an extension, e.g. .jpg
const PUBLIC_FILE = /\.(.*)$/
 
export async function middleware(req: NextRequest) {
  // Cookie locale
  const cookieLocale = req.cookies.get('NEXT_LOCALE')?.value
 
  // Client country, defaults to us
  const country = req.geo?.country?.toLowerCase() || 'us'
 
  // Client language, defaults to en
  const language =
    req.headers
      .get('accept-language')
      ?.split(',')?.[0]
      .split('-')?.[0]
      .toLowerCase() || 'en'
 
  // Helpful console.log for debugging
  // console.log({
  //   country: country,
  //   language: language,
  //   cookieLocale: cookieLocale,
  // });
 
  try {
    // Early return if we do not need to or want to run middleware
    if (
      req.nextUrl.pathname.startsWith('/_next') ||
      req.nextUrl.pathname.includes('/api/') ||
      PUBLIC_FILE.test(req.nextUrl.pathname)
    ) {
      return
    }
 
    // Early return if we are on a locale other than default
    if (req.nextUrl.locale !== 'default') {
      return
    }
 
    // Early return if there is a cookie present and on default locale
    // We can redirect right away to the value of the cookie
    // Still falls back to en just in case
    if (cookieLocale && req.nextUrl.locale === 'default') {
      return NextResponse.redirect(
        new URL(
          `/${cookieLocale}${req.nextUrl.pathname}${req.nextUrl.search}`,
          req.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') {
      return NextResponse.redirect(
        new URL(`/fr-fr${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
 
    // Redirect All Belgium
    if (country === 'be') {
      return NextResponse.redirect(
        new URL(`/fr-be${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
 
    // Redirect all Great Britain
    if (country === 'gb') {
      return NextResponse.redirect(
        new URL(`/en-gb${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
 
    // Redirect French-Canada
    if (country === 'ca' && language === 'fr') {
      return NextResponse.redirect(
        new URL(`/fr-ca${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
 
    // Redirect all other Canadian requests
    if (country === 'ca') {
      return NextResponse.redirect(
        new URL(`/en-ca${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
 
    // Handle French language fallback
    if (language === 'fr') {
      return NextResponse.redirect(
        new URL(`/fr${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
 
    // Handle the default locale fallback to english
    if (req.nextUrl.locale === 'default') {
      return NextResponse.redirect(
        new URL(`/en${req.nextUrl.pathname}${req.nextUrl.search}`, req.url),
      )
    }
  } 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.

LanguageSelect.tsx
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!!