Introduction
Internationalization can be a complex challenge for developers to get right, since it ties in with infrastructure. This post will teach you how to nail internationalized routing and content translations in your Next.js 13 website or web application.
I work in the e-commerce space, with brands spanning countries and continents - almost all of them require different website content per language or region. According to Vercel, the company behind Next.js:
72% of consumers are more likely to stay on your site if it's been translated and 55% of consumers said they only buy from e-commerce sites in their native language.
(quote source)
So it's incredibly important for your sites to have i18n support, to maximize users and conversion rates.
Understanding that, it may surprise you to find out that the new Next.js 13 app
directory drops the i18n support the pages
directory has had built-in since 2020.
Note: this tutorial assumes basic familiarity with the app
directory in Next.js 13. More info linked at the bottom. Additionally, if you get lost, check out the finished code here.
How will I localize my Next.js 13 project without built-in support?
The built-in internationalized routing that launched in Next.js 10 in Oct. 2020 had many limitations. For example, the routing structure was opinionated, requiring a single URL subpath or different TLD, like example.com/en-US, example.com/en-CA
or example.us, example.ca
. URLs with two subpaths was not supported, so example.com/en/us
would be impossible.
A year later, in Oct 2021, Next.js released with support for custom middleware, which allows developers to use our own business logic for routing. With this system, internationalized routing doesn't need to be built-in to the platform, because it's now entirely in our control how Next will route users. Since we control routing, and the file structure, we can now set up more flexible URL structures.
In Next.js 13, while the old pages
directory maintains existing support for i18n routing, it's intentionally removed from app
directory routing to put the power into developers' hands.
Goals
In this tutorial, we'll do something we couldn't do before Next.js 13 - set up international routing and translations using two subpaths like example.com/en/us
. We will include static generation, locale detection, and handling a default locale which shouldn't require the subpath.
Our website will have 3 supported locales corresponding to the following regional dialects:
-
en-US
which should be our default and have no URL subpath, -
en-CA
for Canadian English, -
fr-CA
for Canadian French
Set up the File Structure
Start by creating a Next.js app with the --experimental-app
flag (as of Jan. 2023). We'll use TypeScript too.
Copy all of the content inside app/
into a new folder, app/[lang]/[country]
. Also create a new file at the project-root level called middleware.ts
. Your folder structure should look like this:
In middleware.tsx
, add an import of the Next.js Request type and add an empty default function and a matcher to ignore non-content URLs:
export function middleware(request: NextRequest) {
}
export const config = {
// do not localize next.js paths
matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)",],
};
In page.tsx
replace the default function with the following, which will display the parameters the page is receiving:
export default function Home({
params,
}: {
params: { lang: string; country: string };
}) {
return <h1>{JSON.stringify(params)}</h1>;
}
Now you should be able to run your project with npm run dev
and then navigate to any URL with two parameters, such as localhost:3000/hello/world
, and see something like this:
The URL parameters are working great, but this isn't a valid locale!
Handle Locale Validation, Redirects, and Rewrites with Middleware
We'll use middleware to validate that the user is accessing a valid locale, at the correct URLs, and display the default locale when a user is at the site root with no subpath.
The middleware runs on every request to the site that matches the regex we added above. So, we can use it to redirect the user, or use a "rewrite" which allows us to show one page's content even when the user is on a different URL.
First, let's specify our default locale, list of allowed locales, and a convenience function to help us process the locale parts. (Note: you wouldn't need the convenience function if you only use one subpath, like example.com/en-US
, but we'll use the more complex case in this guide.)
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
const defaultLocale = "en-US";
let locales = ["en-US", "en-CA", "fr-CA"];
type PathnameLocale = {
pathname: string;
locale?: never;
};
type ISOLocale = {
pathname?: never;
locale: string;
};
type LocaleSource = PathnameLocale | ISOLocale;
const getLocalePartsFrom = ({ pathname, locale }: LocaleSource) => {
if (locale) {
const localeParts = locale.toLowerCase().split("-");
return {
lang: localeParts[0],
country: localeParts[1],
};
} else {
const pathnameParts = pathname!.toLowerCase().split("/");
return {
lang: pathnameParts[1],
country: pathnameParts[2],
};
}
};
export function middleware(request: NextRequest) {
}
Next, let's figure out how to send users from /en/us
, our default locale, back to /
. First we'll check if the current requested URL path matches the default locale, and if it does then we'll remove the subpath.
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const defaultLocaleParts = getLocalePartsFrom({ locale: defaultLocale });
const currentPathnameParts = getLocalePartsFrom({ pathname });
// Check if the default locale is in the pathname
if (
currentPathnameParts.lang === defaultLocaleParts.lang &&
currentPathnameParts.country === defaultLocaleParts.country
) {
// we want to REMOVE the default locale from the pathname,
// and later use a rewrite so that Next will still match
// the correct code file as if there was a locale in the pathname
return NextResponse.redirect(
new URL(
pathname.replace(
`/${defaultLocaleParts.lang}/${defaultLocaleParts.country}`,
pathname.startsWith("/") ? "/" : ""
),
request.url
)
);
}
}
With this change, try navigating to localhost:3000/en/us
and you should be redirected to localhost:3000
. If you try navigating to localhost:3000/en/ca
, you should not be redirected.
Now that we're redirecting users from localhost:3000/en/us
and invalid locales to localhost:3000
, we need Next.js to display /
as if it matched the filepath /app/[lang]/[country]
to prevent an incorrect 404, since we don't have a root app/page.tsx
file. We also should serve a 404 if the pathname doesn't match any valid locale. We can kill two birds with one stone using the following loop and rewrite:
const pathnameIsMissingValidLocale = locales.every((locale) => {
const localeParts = getLocalePartsFrom({ locale });
return !pathname.startsWith(`/${localeParts.lang}/${localeParts.country}`);
});
if (pathnameIsMissingValidLocale) {
// rewrite it so next.js will render `/` as if it was `/en/us`
return NextResponse.rewrite(
new URL(
`/${defaultLocaleParts.lang}/${defaultLocaleParts.country}${pathname}`,
request.url
)
);
}
How does this work? At this point, if you try to access localhost:3000/hello/world
, since it doesn't match any locale in our array, Next.js will treat it as localhost:3000/en/us/hello/world
, a file path which doesn't exist, and give you a 404 page.
With that, we've achieved our routing requirements! 🎉
Automatic locale detection
Now that we have a great routing setup, we can optimize the user experience by automatically redirecting users to the correct route based on their location and language preferences. This way they shouldn't need to manually use a language picker on your site (though it may still be a feature you want to make available).
First, run npm i accept-language-parser
and npm i @types/accept-language-parser --save-dev
. accept-language-parser is a small library that will help us process the request headers to find the best language for a user.
Next, add this import to the top of your middleware.ts
file, and add the findBestMatchingLocale
function under the locales declarations:
import langParser from "accept-language-parser";
const defaultLocale = "en-US";
const locales = ["en-US", "en-CA", "fr-CA"];
const findBestMatchingLocale = (acceptLangHeader: string) => {
// parse the locales acceptable in the header, and sort them by priority (q)
const parsedLangs = langParser.parse(acceptLangHeader);
// find the first locale that matches a locale in our list
for (let i = 0; i < parsedLangs.length; i++) {
const parsedLang = parsedLangs[i];
// attempt to match both the language and the country
const matchedLocale = locales.find((locale) => {
const localeParts = getLocalePartsFrom({ locale });
return (
parsedLang.code === localeParts.lang &&
parsedLang.region === localeParts.country
);
});
if (matchedLocale) {
return matchedLocale;
}
// if we didn't find a match for both language and country, try just the language
else {
const matchedLanguage = locales.find((locale) => {
const localeParts = getLocalePartsFrom({ locale });
return parsedLang.code === localeParts.lang;
});
if (matchedLanguage) {
return matchedLanguage;
}
}
}
// if we didn't find a match, return the default locale
return defaultLocale;
};
This function will find the best matching locale by iterating through the priority-sorted locales from the request and comparing them to the available locales on our site. First it will try to find an available locale matching the requested language and country, and if none is found then it will fall back to just matching the language, and finally fall back to the default locale.
In the last step, we rewrote all URLs without a matching locale subpath to use the en-US locale. In this step, we'll redirect the user if there's a matching locale other than the default. If the default is the best match, we'll use the same rewrite as before.
Update the final if
-statement in the middleware file to the following:
if (pathnameIsMissingValidLocale) {
// rewrite it so next.js will render `/` as if it was `/en/us`
const matchedLocale = findBestMatchingLocale(
request.headers.get("Accept-Language") || defaultLocale
);
if (matchedLocale !== defaultLocale) {
const matchedLocaleParts = getLocalePartsFrom({ locale: matchedLocale });
return NextResponse.redirect(
new URL(
`/${matchedLocaleParts.lang}/${matchedLocaleParts.country}${pathname}`,
request.url
)
);
} else {
return NextResponse.rewrite(
new URL(
`/${defaultLocaleParts.lang}/${defaultLocaleParts.country}${pathname}`,
request.url
)
);
}
}
How do we test this? Chrome allows us to simulate locales from different requests.
In Chrome dev tools, press cmd + shift + P
and type in sensors
Select "Show sensors" to open the menu, and add one of our supported locales:
Now if you attempt to navigate to the homepage without a sub-domain, you should be redirected to /fr/ca
. Even if you enter fr-FR
(French standard, rather than Canadian French), you'll still be redirectedto the French Canadian subpath instead of the English subpath. Voila! 🎊
Content Translation
For content translation, we'll use the official Next.js 13 guide to localization as a foundation, but add variable interpolation.
Start by moving all of our locale code out of middleware.ts
into a new file also at the project root, i18n.ts
:
// i18n.ts
export const defaultLocale = "en-US";
export const locales = ["en-US", "en-CA", "fr-CA"] as const;
export type ValidLocale = typeof locales[number];
type PathnameLocale = {
pathname: string;
locale?: never;
};
type ISOLocale = {
pathname?: never;
locale: string;
};
type LocaleSource = PathnameLocale | ISOLocale;
export const getLocalePartsFrom = ({ pathname, locale }: LocaleSource) => {
if (locale) {
const localeParts = locale.toLowerCase().split("-");
return {
lang: localeParts[0],
country: localeParts[1],
};
} else {
const pathnameParts = pathname!.toLowerCase().split("/");
return {
lang: pathnameParts[1],
country: pathnameParts[2],
};
}
};
I also added the as const
directive to add a ValidLocale
enum based on the locales array. Make sure to update the imports in your middleware.tsx
to reflect these changes.
Next, add a dictionaries
folder in your project root with three JSON files, one for each locale:
(dictionaries/en-CA.json)
{
"welcome": {
"helloWorld": "Hello World, eh?",
"happyYear": "Happy {{ year }}!"
}
}
(dictionaries/en-US.json)
{
"welcome": {
"helloWorld": "Hello World!",
"happyYear": "Happy {{ year }}!"
}
}
(dictionaries/fr-CA.json)
{
"welcome": {
"helloWorld": "Salut le Monde!",
"happyYear": "Bonne année {{ year }}!"
}
}
Next we'll add a translation function into i18n.ts
which can be called by server components to import the dictionary and get the requested translation. This function will also handle variable interpolation similar to i18next
// i18n.ts
const dictionaries: Record<ValidLocale, any> = {
"en-US": () =>
import("dictionaries/en-US.json").then((module) => module.default),
"en-CA": () =>
import("dictionaries/en-CA.json").then((module) => module.default),
"fr-CA": () =>
import("dictionaries/fr-CA.json").then((module) => module.default),
} as const;
export const getTranslator = async (locale: ValidLocale) => {
const dictionary = await dictionaries[locale]();
return (key: string, params?: { [key: string]: string | number }) => {
let translation = key
.split(".")
.reduce((obj, key) => obj && obj[key], dictionary);
if (!translation) {
return key;
}
if (params && Object.entries(params).length) {
Object.entries(params).forEach(([key, value]) => {
translation = translation!.replace(`{{ ${key} }}`, String(value));
});
}
return translation;
};
};
We can now use the translations in our page.tsx
file. Since the function to import the translation files is async, we'll need to make the Home()
function async as well:
// page.tsx
import { getLocalePartsFrom, locales, ValidLocale, getTranslator } from "@/i18n";
export default async function Home({
params,
}: {
params: { lang: string; country: string };
}) {
const translate = await getTranslator(
`${params.lang}-${params.country.toUpperCase()}` as ValidLocale // our middleware ensures this is valid
);
return (
<div>
<h1>{translate("welcome.helloWorld")}</h1>
<h2>
{translate("welcome.happyYear", {
year: new Date().getFullYear(),
})}
</h2>
</div>
);
}
The translate
function is created based off of the current locale subpath the user is on, and then content can be translated by adding keys to our JSON files. If there are any keys missing translations, the key itself will be rendered. And what's best - since this is all server-side code, we don't need to ship a huge JSON translations object to the browser.
How do I translate content in client components?
We're able to make our Home() function above asynchronous, and use getTranslator
, because Home() is a server component.
We can't make client components asynchronous, so this is what I would recommend:
- Maximize usage of server components.
- Don't forget that you can render a server component as a child of a client component (see here for info). This means you can often break out static text elements into a separate server component.
- For text that you unavoidably need to translate in a client component, you can execute the translations on the server component and pass the text into the client component as a prop.
If #3 concerns you, thinking about cases of prop-drilling, consider point #2 again. It should be rare to have deeply-nested child components that don't have a server-component parent or grandparent.
Use generateStaticParams to statically generate the localized pages and prevent build errors
If you try to build the project after the localization steps above, you should get an error, because Next will attempt to import a dictionary for the generic case of any string locales.
To fix this, we need to tell Next to only statically generate pages for our valid locales.
Add this function in page.tsx
:
import { ....... } from "@/i18n";
export async function generateStaticParams() {
return locales.map((locale) => getLocalePartsFrom({ locale }));
}
export default async function Home({
.......
Run yarn build
to confirm everything is working, and the project should build! ✅
Then run yarn start
and navigate to one of the localized subpaths to see the translations.
So far, I've had to include the same snippet on every page I create. For example:
// /app/[lang]/[country]/examplePage/page.tsx
import { getTranslator } from "@/i18n";
import { getLocalePartsFrom, locales, ValidLocale } from "@/i18n";
export async function generateStaticParams() {
return locales.map((locale) => getLocalePartsFrom({ locale }));
}
export default async function ExamplePage({
params,
}: {
params: { lang: string; country: string };
}) {
const translate = await getTranslator(
`${params.lang}-${params.country.toUpperCase()}` as ValidLocale // our middleware ensures this is valid
);
return (
<div>
<h1>Example page: {translate("welcome.helloWorld")}</h1>
</div>
);
}
Taking it further
Ideas to consider:
- You could test out different URL structures, such as single-subpath locales or different domains, by customizing the routing rules in the middleware.
- You may want to customize the translation rules for missing keys. For example, instead of returning the key, return the default locale's text for that key.
- You may want to have some pages unlocalized, which you can add in
/app
- You could build a frontend language picker component.
- Make the
dictionaries
object dynamic based on the allowed locales
Conclusion!
- The
app
directory in Next.js 13 doesn't include the built-in localization system of thepages
directory. - Instead, it gives us more flexibility via middleware and powerful server-side rendering.
- We used middleware logic to redirect users and rewrite pages based on the requested URL and each user-requests's Accept-Language header.
- We used the power of server components to translate content and reviewed methods of translating client component content
- We used generateStaticParams to correctly build the project
Thanks for reading, hope this helps! If you find anything wrong, please let me know in the comments or on Twitter
A big thanks to everyone who has contributed ideas on i18n in this GitHub issue
Next.js 13 App directory docs
Next.js 13 guide to localization
Top comments (10)
Thank you for this tutorial, it's working fine for me :).
Instead of returning the key in case there is no local word we can also back-up to the default dictionary:
What if I want to make the root path as default language like this:
http://localhost:3000
==> will been
without showing the/en
prefix because it's the default language.http://localhost:3000/f
r it will translate tofr
How can I achieve this?
Yes, you can do this. If you don't want to write your own middleware you can use library
next-intl
and install beta version. Check the link here. It has similar configuration like previous version of build-in support for i18n for pages dir, so you can set it likelocales: ['en', 'fr'],
defaultLocale: 'en',
and defaultLocale will be just / without /en.
It works for me ;)
if (locale === DEFAULT_LOCALE) {
return NextResponse.rewrite(new URL(
${locale}${pathname}
, request.url));}
This is what I have been looking for. Thank you so much.
Tried to create a pull request for your repository. When using 'use client', I needed this:
it is a bad example
Could u tell why ? I think this example only fits with pages not components, is this true?
How does it handle intervals? I mean, if I have to interpolate a number and deal with plurals
Great write-up, helped me more than the docs! Especially the part of translating in child components because that had me wondering how to achieve it.