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 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 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 thesrc
folder if you are using it. During the beta release the middleware file was located inside of thepages
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!
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.
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 →
</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>✓</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!!