automatic i18n linking with nextjs 13 app router
October 10, 2023
automatic internationalized links and routing using NextJS's Link component and app router
Yesterday, I posted about implementing i18n routing in a nextjs project, focusing on the app
folder and middleware setup. This post follows from that post with an I18NLink
component that leverages our i18n set up to automatically prefix your links with the user's current language. This way, you can do something like this:
<I18NLink href="/settings">{t('settings', 'Settings')}</I18NLink>
And the link will automatically pick up the lang
param to route to either /settings
or (for example) /es/settings
depending on the lang
in the url.
Note: the t
function is a stand in for a generic translation function that would take a key like settings
and return a string or fallback to the second argument, which is "Settings".
wrapping the Link
component
The first thing we need to do is wrap NextJS's default Link
component:
import type { ComponentProps } from 'react';
import React from 'react';
import Link from 'next/link';
export type I18NLinkType = Omit<ComponentProps<typeof Link>, 'as'> & {
href: string;
};
function I18NLink({ children, href, ...props }: I18NLinkType) {
return (
<Link href={href} {...props}>
{children}
</Link>
)
}
A few things are happening here:
- We are creating a new
I18NLinkType
by taking theLink
type and omittingas
so we can use the component polymorphically. - We are defining
href
as a string sinceLink
potentially accepts aURL
. - We then create a function that's more or less a pass-through: take the
href
andchildren
and just pass them toLink
.
This isn't doing too much yet. We need to hook it into the useParams
hook.
get current lang
param
Let's update the component:
import { useParams } from 'next/navigation';
// generic import of your default language
import { defaultLanguage } from 'settings';
function I18NLink({ children, href, ...props }: I18NLinkType) {
const params = useParams();
const currentLanguage = (params?.lang as string) || defaultLanguage;
return (
<Link href={`${currentLanguage}${href}`} {...props}>
{children}
</Link>
)
}
Note that params
could be null
or an empty Record
, so we want to coerce lang
as a string.
This isn't a bad solution, but we're going to run into a few problems:
- We don't want to prepend the
defaultLanguage
if we don't have to since it'll cause an extra redirect in the middleware. - We can't use the Link generically since it'll only work for relative paths. If we pass an absolute or external route, the url will be malformed.
checking incoming href
What we need to do to prevent the above is check the incoming href
and make decisions about what href
to actually pass to Link
.
function getPath(path?: string | null) {
// 1.
if (!path) {
return ['en', ''];
}
// 2.
const pathArray = path.split('/').filter((x) => !!x);
// 3. empty array
if (pathArray.length === 0) {
return ['en', ''];
}
// 4.
const [lang, ...restPath] = pathArray;
// 5.
if (languageArray.includes(lang)) {
return [lang, restPath.join('/')];
}
// 6.
return ['en', pathArray.join('/')];
}
function getPrefixedUrl(href: string, currentLanguage: string) {
// 1. handle absolute links
if (/^((http|https):\/\/)/.test(href)) {
return href;
}
// 2.
const [, tail] = getPath(href);
// 3.
if (currentLanguage === defaultLanguage) {
return `/${tail}`;
}
// 4.
return `/${currentLanguage}/${tail}`;
}
Let's start with getPrefixedUrl
:
- If the
href
starts withhttp
orhttps
, we should follow the absolute route provided. - Otherwise, we want to get the url path without the
lang
param if it's provided. - If the
currentLanguage
is also the default language, just pass thetail
of the path - Otherwise, prepend the current language to the
tail
of the path
getPath
might seem a little extraneous or verbose, but it's meant to help avoid issues with passing around the root and with overriding the lang
provided in the url.
- If the
path
is null, return an array we can use to route the user to the root of the site. - Take the path, and split it at
/
. The array is going to be easier to work with. - If the array is empty because
path === '/'
, return an array we can use to route the user to the root of the site. - We want to deal with just
restPath
if (and only if) the first part of the url is actually anlang
param. - We do a check to ensure that
lang
is include in ourlangaugeArray
, which tells us we can safely userestPath
- Otherwise we want to return the whole path we were provided
With these helper functions, we can update the component:
function I18NLink({ children, href, ...props }: I18NLinkType) {
const params = useParams();
const currentLanguage = (params?.lang as string) || defaultLanguage;
const prefixedHref = getPrefixedUrl(href, currentLanguage);
return (
<Link href={prefixedHref} {...props}>
{children}
</Link>
)
}
bonus: anchor or Link
If you want to escape the client cache with a "hard" route, you might want to be able to use either an anchor or Link
component:
export type I18NLinkType = Omit<ComponentProps<typeof Link>, 'as'> & {
isAnchor?: boolean;
href: string;
};
function I18NLink({ children, href, isAnchor, ...props }: I18NLinkType) {
const params = useParams();
const currentLanguage = (params?.lang as string) || defaultLanguage;
const prefixedHref = getPrefixedUrl(href, currentLanguage);
if (isAnchor) {
return (
<a href={prefixedHref} {...props}>
{children}
</a>
);
}
return (
<Link href={prefixedHref} {...props}>
{children}
</Link>
)
}
We can just use a boolean
and render an anchor tag based on isAnchor
if passed.
wrapping up
It would be great to have a more built in way to handle internationalization in NextJS, however it only requires a little bit of boilerplate to get a full fledged i18n setup, including automatic i18n routing with NextJS's default Link
component.
Check out the full gist here.