
Advanced internationalization with Next.js and middleware
February 24, 2022 • 2 Min
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 Britainen-ca
, English-Canadafr-fr
, French-Francefr-be
, French-Belgiumfr-ca
, French-Canadafr
, French language fallbacken
, 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 applicationlocales: ["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.jsimport { NextResponse } from "next/server"// Regex to check whether something has an extension, e.g. .jpgconst PUBLIC_FILE = /\.(.*)$/// Next JS Middlewareexport const middleware = (request) => {// Get the information we need from the request objectconst { nextUrl, geo, headers, cookies } = request// Cloned url to work withconst url = nextUrl.clone()// Client country, defaults to usconst country = geo?.country?.toLowerCase() || "us"// Client language, defaults to enconst 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 imageif (PUBLIC_FILE.test(nextUrl.pathname)) {return undefined}// Early return if this is an api routeif (nextUrl.pathname.includes("/api")) {return undefined}// Early return if we are on a locale other than defaultif (nextUrl.locale !== "default") {return undefined}// Early return if there is a cookie present and on default localeif (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 Franceif (country === "fr") {url.pathname = `/fr-fr${nextUrl.pathname}`return NextResponse.redirect(url)}// Redirect All Belgiumif (country === "be") {url.pathname = `/fr-be${nextUrl.pathname}`return NextResponse.redirect(url)}// Redirect all Great Britainif (country === "gb") {url.pathname = `/en-gb${nextUrl.pathname}`return NextResponse.redirect(url)}// Redirect French-Canadaif (country === "ca" && language === "fr") {url.pathname = `/fr-ca${nextUrl.pathname}`return NextResponse.redirect(url)}// Redirect all other Canadian requestsif (country === "ca") {url.pathname = `/en-ca${nextUrl.pathname}`return NextResponse.redirect(url)}// Handle French language fallbackif (language === "fr") {url.pathname = `/fr${nextUrl.pathname}`return NextResponse.redirect(url)}// Handle the default locale fallback to englishif (nextUrl.locale === "default") {url.pathname = `/en${nextUrl.pathname}`return NextResponse.redirect(url)}// If everything else falls through continue on with response as normalreturn 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 necessaryimport * 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 routerconst router = useRouter()const { pathname, asPath, query, locale = "en" } = router// State for the currently selected localeconst [selectedLang, setSelectedLang] = useState(locale)// State for whether the popover is open or notconst [isPopoverOpen, setIsPopoverOpen] = useState(false)// Handle button clickconst handleClick = (languageLocale) => {setSelectedLang(languageLocale)setIsPopoverOpen(false)}// Update the router and locale if the selected language is changeduseEffect(() => {// Get the full cookie consentconst cookieConsent = document.cookie? document.cookie.split("; ").find((row) => row.startsWith("hasCookieConsent=")): null// Get the value of the cookie, note this will be a stringconst cookieConsentString = cookieConsent? cookieConsent.split("=")[1]: false// Extract the value to a boolean we can use more easilyconst hasCookieConsent = cookieConsentString === "true"if (selectedLang === "en") {// If we have consent set a cookieif (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 cookieif (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 cookieif (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 cookieif (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 cookieif (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 cookieif (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 cookieif (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-linereturn (<Popover.Root open={isPopoverOpen}><Popover.Trigger onClick={() => setIsPopoverOpen(!isPopoverOpen)}>Change language →</Popover.Trigger><Popover.Content style={{ backgroundColor: "#ccc" }}><Popover.Arrow /><Popover.Close onClick={() => setIsPopoverOpen(!isPopoverOpen)}>Close</Popover.Close><divstyle={{padding: "1rem",display: "grid",gridTemplateColumns: "130px 130px",gridAutoFlow: "dense",gap: "0.5rem",}}>{languages.map((language) => {const isActive = language.locale === localereturn (<button onClick={() => handleClick(language.locale)}>{language.name}{isActive && <span>✓</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