Internationalization (i18n) for Next.js - A Deep Dive

10-14min read
Two astronauts in a spaceship communicating in different languages

Internationalization (i18n) with Next.js app router requires a multi-component strategy including language detection, language preferences, and language translation. In this deep-dive post, we'll explore our current strategy at Infonomic based on key strategic goals.

The TL;DR - we tried a few different approaches based on well-known libraries and plugins, and in the end wrote our own solution including ICU template parsing for translation strings. It turned out better than expected.

Strategic Goals

  1. We'd like 'clean' URLs for the default language - i.e., https://foo.com for English, and https://foo.com/es for Spanish.
  2. We need to differentiate between 'interface' translation (say between English and Spanish), and 'content' translation - which means that content may appear in more than just the interface languages (say English, Spanish, French, and German). This might sound unusual, but it's something we do often here at our agency based in S.E. Asia. We may offer menus, buttons, labels in our 'interface' languages of English and Thai, but may also present multiple language versions of research-based content in Lao, Vietnamese, Khmer etc.
  3. We'd like to store and remember a user's Language preference in a cookie - but only if they have actively chosen a language on the site. Until that choice is made, we will prioritize path followed by browser language detection for language selection. We'd also like the user to be able to 'revert' to the 'detected' default.
  4. We'd like to be able to create links anywhere on the site that direct a user to a language version of content. For example, a user may be browsing the site in Spanish, but we have an FAQ or doc link that directs a user to specific language versions of a particular document or page.

There are a number of blogs posts and articles related to internationalization (i18n) and Next.js app router including the official Internationalization docs here at Next.js. Importantly, Next.js does not provide an 'out of the box' solution for i18n, but rather guidance on how to implement your particular i18n strategy.

This is a long-ish post, with several code components - although hopefully presented in a helpful manner. We'll attempt to setup a dedicated repo soon with a complete working example.

Design Choices

In addition to our strategic goals above, we've made the following design choices:

  1. Our 'language detection' algorithm and priority is: cookie, path, Accept-Language HTTP header, and if none of those work then the site default language. We will only set a cookie if the user makes an active choice via our language menu. This means that until a cookie is set, a locale in the path portion of the URL will take priority. It also means that if someone shares a link in a given language, the receiver of that link will 'see' the link in the intended language - unless they have a cookie set. If a cookie has been set, the application will attempt to 'change' the received link to the user's preferred language.
  2. We'd like to implement our cookie setting strategy using a React Server Function and not via the client.
  3. We'd like some general purpose 'navigate' helper functions for browsing, language links as well as any special handlers that may be used to change languages.
  4. We'd like to kill two birds with one stone and create a LangLink link component that wraps the official Next.js Link component, and handles all of our language links, as well as provides a progress indicator when navigating between pages on the site - using the solution suggested by @leeerob here in this discussion [next/navigation] Next 13: useRouter events?

Let's Go

Here's our top-level Next.js app directory structure. Our preference is to treat the app directory like a 'router' directory (a bit like Remix) - containing only Next.js's specially named files for the router - layouts, pages, etc. We place functionally related code in our modules directory.

src
├── app
├── hooks
├── i18n
├── images
├── lib
├── middleware
├── modules
├── ui
├── utils
└── middleware.ts

And here's the directory structure in our app directory...

app
├── [lng]
│ ├── page.tsx
│ ├── layout.tsx
│ ├── not-found.tsx
│ └── providers.tsx
└── keep-alive
└── route.ts

We have a top-level dynamic language parameter route called [lng] which determines the current language of the site and is available to all React server pages and layouts as....

import { Locale } from '@/i18n/i18n-config'
export default function HomePage({
params: { lng },
}: {
params: { lng: Locale }
}): React.JSX.Element {
return (
<Section className="dark bg-theme-900 relative py-6 flex flex-1 w-full overflow-hidden">
<Container className="prose min-h-[440px] sm:min-h-[480px] dark:prose-invert flex flex-col items-center justify-center">
<Branding lng={lng} />
</Container>
</Section>
)
}

Notice that there's a React client component called Branding - that takes the lng param as a prop. If you're going to build a multilingual site, you need to be sure that the lng param is available to all of your components, whether via props, context, or some other state management strategy.

Here's the contents of our i18n directory...

i18n
├── client
│ ├── index.ts
│ └── translations-provider.tsx
├── components
│ ├── available-languages.tsx
│ ├── lang-link.tsx
│ └── language-menu.tsx
├── hooks
│ └── use-lang-navigation.ts
├── server
│ ├── @types.ts
│ ├── index.test.ts
│ └── index.ts
├── translations
│ ├── en.json
│ └── es.json
├── i18n-config.ts
├── language-map.ts
├── migrate-t.ts
├── set-language-action.ts
└── utils.ts

Language Detection, Routing, Switching and Helpers

It's important to separate all of the language detection, routing, navigation, switching and language links from actual translations. They're effectively two separate systems within your i18n strategy. In this section we'll look at just the language detection, routing navigation, language switching and helpers, before looking at how we render translation strings below.

The heart of our language detection strategy and language router - including 'clean' URLs for the default language is handled via middleware. You can read more about middleware chaining in a post here over at 58bits.com - chaining-or-combining-nextjs-middleware

Here's our i18n middleware plugin including a helper function to detect the current language:

get-locale.ts

import { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { Locale, i18nConfig } from '@/i18n/i18n-config'
import { localeFromPath } from '@/i18n/utils'
/**
* Current detection strategy is 1) cookie, 2) path, 3) user agent, 4) default
* @param request
* @returns string locale
*/
export function getLocale(request: NextRequest): string {
let locale
// 1. Cookie detection first
if (request.cookies.has(i18nConfig.cookieName)) {
locale = request?.cookies?.get(i18nConfig.cookieName)?.value
// Double check that the cookie value is actually a valid
// locale (it may have been 'fiddled' with)
if (locale != null && i18nConfig.locales.includes(locale as Locale) === false) {
locale = undefined
}
}
// 2. Path detection second
if (locale == null) {
const pathname = request.nextUrl.pathname
locale = localeFromPath(pathname, false)
}
// 3. Browser / user agent locales third
if (locale == null) {
// NOTE: @formatjs/intl-localematcher will fail with RangeError: Incorrect locale information provided
// of there is no locale information in the request (for example when benchmarking the application).
// This will result in a request failure - 500 server error response. Not good.
try {
// Negotiator expects plain object so we need to transform headers
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value))
let browserLanguages = new Negotiator({ headers: negotiatorHeaders }).languages()
locale = match(browserLanguages, i18nConfig.locales, i18nConfig.defaultLocale)
} catch (error) {
// console.warn(`Failed to match locale: ${error}`)
locale = i18nConfig.defaultLocale
}
}
// 4. Lastly - fallback to default locale
if (locale == null) {
locale = i18nConfig.defaultLocale
}
return locale
}

with-i18n.ts below, note that you can simply extract the middleware function below if you don't intend to use the chaining strategy described in chaining-or-combining-nextjs-middleware. If the incoming request does not have a locale in the path and our detection strategy via getLocale returns the default locale - then we will rewrite the URL internally via NextResponse.rewrite ensuring that the default locale is now in the path as far as the rest of the application is concerned, without changing the path in the browser - hence the 'clean' URL for the default language - https://foo.com for English and https://foo.com/es for Spanish.

with-i18n.ts

import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from '@/i18n/i18n-config'
import { getLocale } from './get-locale'
import type { MiddlewareFactory } from '../@types'
export const withI18n: MiddlewareFactory = (next: NextMiddleware) => {
return async (request: NextRequest, _next: NextFetchEvent) => {
const pathname = request.nextUrl.pathname
const locale = getLocale(request)
const localeInPath = i18nConfig.locales.find((locale) => {
return pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
})
// Locale is NOT in the path
if (localeInPath == null) {
// Used for either a rewrite, or redirect in the case of the default
// language. Also ensure that any query string values are preserved
// via request.nextUrl.search
let path = `/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`
if (request.nextUrl?.search != null) {
path += request.nextUrl.search
}
if (locale === i18nConfig.defaultLocale) {
// Default language - so leave it out of the visible browser URL,
// but rewrite for Next.js locale params support.
return NextResponse.rewrite(new URL(path, request.url))
} else {
// NOT the default language - so redirect with the new 'locale'
// containing URL.
return NextResponse.redirect(new URL(path, request.url))
}
} else {
// We have a locale in the URL, so check to see it matches
// the detected locale from getLocale above.
if (localeInPath !== locale) {
// There's a mismatch
let path: string
if (locale === i18nConfig.defaultLocale) {
path = pathname.includes(`/${localeInPath}/`)
? pathname.replace(`/${localeInPath}`, '')
: pathname.replace(`/${localeInPath}`, '/')
if (request.nextUrl?.search != null) {
path += request.nextUrl.search
}
} else {
path = pathname.includes(`/${localeInPath}/`)
? pathname.replace(`/${localeInPath}`, locale)
: pathname.replace(`/${localeInPath}`, `/${locale}`)
if (request.nextUrl?.search != null) {
path += request.nextUrl.search
}
}
return NextResponse.redirect(new URL(path, request.url))
} else {
return next(request, _next)
}
}
}
}

With get-locale.ts and with-i18n.ts we have everything we need to correctly detect and route site languages, ensuring that the current language is available across the application via our [lng] dynamic route parameter.

The next two pieces we need are a language switcher that allows a user to change languages on the site, and a 'language-aware' Link component that wraps the native Next.js Link component.

Here's our client language switching menu, which is using a React Server Function to set a cookie based on the user's choice. Note that we're using a wrapped version of Radix UI Dropdown Menu. First the component...

language-menu.tsx

'use client'
import { useActionState, startTransition } from 'react'
import {
GlobeIcon,
CheckIcon,
SettingsGearIcon,
Dropdown as DropdownMenu,
} from '@infonomic/uikit-client'
import cx from 'classnames'
import { usePathname, useSearchParams } from 'next/navigation'
import { interfaceLanguageMap as languageMap } from '@/i18n/language-map'
import { SetLanguageActionState, setLanguageAction } from '@/i18n/set-language-action'
type LanguageMenuIntrinsicProps = React.JSX.IntrinsicElements['div']
interface LanguageMenuProps extends LanguageMenuIntrinsicProps {
className?: string
shiftMenu?: boolean
color?: string
lng: string
}
export function LanguageMenu({
className,
lng,
color,
shiftMenu = true,
}: LanguageMenuProps): React.JSX.Element {
const pathname = usePathname()
const searchParams = useSearchParams()
const initialState: SetLanguageActionState = { message: undefined, status: 'idle' }
const [_, dispatch] = useActionState(setLanguageAction, initialState)
const handleOnSelect = (event: Event) => {
// @ts-ignore
let lng = event?.target?.dataset.language
const data = new FormData()
data.set('lng', lng)
data.set('pathname', pathname)
data.set('searchParams', searchParams.toString())
startTransition(() => {
dispatch(data)
})
}
const menuItemClasses = cx(
'flex gap-1 w-full rounded px-[2px] py-[5px] md:text-sm',
'hover:bg-canvas-50/30 dark:hover:bg-canvas-900',
'cursor-default select-none items-center outline-none',
'text-gray-600 focus:bg-canvas-50/30 dark:text-gray-300 dark:focus:bg-canvas-900'
)
return (
<div className={className}>
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>
<button
aria-label={languageMap[lng]?.nativeName}
className="component--language-menu rounded flex items-center justify-between gap-1 outline-none"
role="button"
>
<GlobeIcon svgClassName={color} />
<span className={cx(color, 'hidden sm:inline mr-[4px]')}>
{languageMap[lng]?.nativeName}
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="center"
sideOffset={shiftMenu ? 10 : 10}
className={cx(
'z-40 rounded radix-side-bottom:animate-slide-down radix-side-top:animate-slide-up',
'w-28 px-1.5 py-1 shadow-md',
'bg-white dark:bg-canvas-800 border dark:border-canvas-700 shadow'
)}
>
{Object.keys(languageMap).map((language) => {
const active = lng === language
return (
<DropdownMenu.Item
key={language}
onSelect={handleOnSelect}
className={menuItemClasses}
data-language={language}
>
<div className="flex">
<span className="inline-block w-[22px]">
{active && <CheckIcon width="18px" height="18px" />}
</span>
<span className="text-left inline-block w-full flex-1 self-start text-black dark:text-gray-300">
{languageMap[language].nativeName}
</span>
</div>
</DropdownMenu.Item>
)
})}
<div className="divider my-1 border-t border-t-gray-300 dark:border-t-gray-700 w-[90%] mx-auto"></div>
<button
tabIndex={0}
className={cx(
'settings rounded w-full flex gap-1 select-none items-center pl-[3px] pr-[2px] py-[5px] text-sm outline-none',
'text-gray-800 dark:text-gray-100 hover:bg-canvas-50/30 dark:hover:bg-canvas-900'
)}
>
<SettingsGearIcon svgClassName="stroke-black" width="15px" height="15px" />
<span className="inline-block ml-[1px]">Settings</span>
</button>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
)
}

And then our React Server Function . Note: Be super careful about redirects in server functions. If you implement any try / catch blocks, you MUST handle the case of a redirect since the redirect is triggered by Next.js throwing a redirect NEXT_REDIRECT error object.

set-language-action.ts

'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { Locale, i18nConfig } from './i18n-config'
import { pathWithoutLocale } from './utils'
import { getLogger } from '@/lib/logger'
export interface SetLanguageActionState {
message?: string
status: 'success' | 'failed' | 'idle'
}
export async function setLanguageAction(
prevState: SetLanguageActionState,
formData: FormData
): Promise<SetLanguageActionState> {
const logger = getLogger()
const lng = formData.get('lng') as Locale
const pathname = formData.get('pathname') as string
const searchParams = formData.get('searchParams') as string
if (lng != null && i18nConfig.locales.includes(lng) === true) {
cookies().set({
name: i18nConfig.cookieName,
value: lng,
sameSite: 'lax',
httpOnly: false,
maxAge: 1704085200, // 365 days in the future for Chrome compatibility (max 400 days)
path: '/',
})
let path = pathWithoutLocale(pathname)
if (lng !== i18nConfig.defaultLocale) {
path = `/${lng}${path}`
}
if (searchParams != null && searchParams.length > 0) {
path += `?${searchParams}`
}
redirect(path)
} else {
const status: SetLanguageActionState = {
status: 'failed',
message: 'Error calling setLanguage - language value not found or invalid language value.',
}
logger.error({
set_language: { ...status, method: 'setLanguage' },
})
return status
}
}

Calling the server action (now officially referred to as a server function) via handleOnSelect in our language switcher component, will cause the page to reload, re-running middleware resulting in our new language choice becoming active.

That gives us the core of our language detection, routing, language switching strategy. The only thing we'll add as a 'nice touch' is a language settings option. Our language detection algorithm is 1) cookie, 2) path, 3) browser / user agent locales. But... once a cookie is set, the user can't easily switch back to the browser / user agent default. So we'll give them a settings modal where they can see their current detection method, and if a cookie has been set, give them an option to 'reset' their language choice to system default. A screenshot of a web application with language menu and settingsOur language menu with a settings option that will allow a user to reset to system default.

Lastly, and not least, we've created a custom Link component called LangLink that wraps Next.js Link component. This also gives us a chance to implement a progress indicator for client router navigation based on this discussion here and this example here... https://github.com/vercel/react-transition-progress (although note this issue - as we've now implemented our own version of this component)

Our LangLink component also has a few tricks for href syntax. We've also refactored most of the LangLink functionality into a hook which exports a navigate function which has gotten us out of trouble more than once when we've wanted more control over third-party components that offer good event handlers, but don't wrap child components very well.

Here's the hook first....

i18n/hooks/use-lang-navigation.ts

'use client'
import { useActionState, startTransition } from 'react'
import { useCookies } from 'react-cookie'
import { useRouter, usePathname } from 'next/navigation'
import { useProgress } from 'react-transition-progress'
import { i18nConfig } from '@/i18n/i18n-config'
import { localeFromPath, pathWithoutLocale } from '@/i18n/utils'
import { setLanguageAction, SetLanguageActionState } from '@/i18n/set-language-action'
interface NavigateOptions {
href: string
locale?: string
replace?: boolean
scroll?: boolean
smoothScrollToTop?: boolean
}
export const useLangNavigation = (lng: string) => {
const [cookies] = useCookies([i18nConfig.cookieName])
const initialState: SetLanguageActionState = { message: undefined, status: 'idle' }
const [_, dispatch] = useActionState(setLanguageAction, initialState)
const router = useRouter()
const startProgress = useProgress()
// Note: pathname will include the locale!
const pathname = usePathname()
/**
* getHref helper method will prepare the final path for
* the client router including the locale if not the default.
*
* We don't need to append the default locale to a client route
* as our current locale system removes this locale from URLs
* creating clean URLs for the default language.
*
* We have special syntax that allows an href to be a dot (.)
* which means the current path, or the href to begin with
* a question mark (?) which means the current path plus query
* string values. These are useful for cases like pagination,
* where page 1 should be a canonical URL - with no query string
* values (like ?page=2, ?page=3 etc.)
*
* We can also handle the case where there is a requested change
* in locale via the locale param if provided, otherwise we'll
* prepare the href based on the current locale (default language
* or not)
*
* @param {string} href
* @param {string} locale
* @returns {string}
*/
const getHref = (href: string, locale?: string): string => {
let h = `${href}` // clone href
// Set the requested locale to either the locale parameter,
// or the current locale
const requestedLocale = locale ?? lng
// Handle all cases...
if (h.startsWith('?') === true) {
// 1. We have a query string only. If we're not in the default
// locale - append the pathname which will include the
// current locale, otherwise just pass the querystring on
// to be used in the client router.
if (requestedLocale === lng) {
// There's no locale change - so build h with pathname (which includes current locale)
// the client router accepts query string only values so h is fine for the default
// locale on the current path.
h = requestedLocale !== i18nConfig.defaultLocale ? `${pathname}${h}` : h
} else {
// There's a locale change - so build h with requested locale.
// Remove the current locale from pathname
const withoutLocale = pathWithoutLocale(pathname)
h =
requestedLocale !== i18nConfig.defaultLocale
? `/${requestedLocale}${withoutLocale}${h}`
: `/${withoutLocale}${h}`
}
} else if (h === '.') {
// 2. href with a dot which means the current path.
// This is special syntax for things like our router-pager to force
// a navigation to the current path WITHOUT any querystring params
// i.e. going back to page 1 without page 1 in the querystring
// in order to create a canonical URL for page 1
// Again - the pathname will contain the locale if there is one.
if (requestedLocale === lng) {
h = pathname // we're done
} else {
// There's a locale change - so build h with requested locale
const withoutLocale = pathWithoutLocale(pathname)
h =
requestedLocale !== i18nConfig.defaultLocale
? `/${requestedLocale}${withoutLocale}`
: withoutLocale
}
} else if (h.startsWith('/') === true) {
// 3. the href started with a forward slash - relative path
// and we never supply the locale to href in LangLink (it's passed
// via the lng prop) so go ahead and append the locale if needed
// this works whether there is a locale change or not
h = requestedLocale !== i18nConfig.defaultLocale ? `/${requestedLocale}${h}` : h
} else {
// 4. href didn't start with a forward slash, or a dot, or a ?
// so build the new path with locale.
// this works whether there is a locale change or not
h = requestedLocale !== i18nConfig.defaultLocale ? `/${requestedLocale}/${h}` : `/${h}`
}
return h
}
// If the requested language is NOT the current language in the
// lng cookie it means the user is visiting a link that is specifically
// asking for a change in language - for example, a page that includes links
// to different language versions of the page. /es/mypage /fr/mypage etc.
// and so instead of a client router event, we need to call our
// server action - setLanguageAction
const navigate = ({
href,
locale,
replace = false,
scroll = true,
smoothScrollToTop = false,
}: NavigateOptions) => {
let h = getHref(href, locale)
const requestedLocale = localeFromPath(h) as string
if (
cookies[i18nConfig.cookieName] == null ||
(cookies[i18nConfig.cookieName] != null && cookies[i18nConfig.cookieName] === requestedLocale)
) {
// If there is no cookie, or if the cookie matches the
// requested locale - we can perform a 'normal' router
// navigation
startTransition(() => {
startProgress()
if (replace) {
router.replace(h, { scroll: scroll })
} else {
router.push(h, { scroll: scroll })
}
})
if (smoothScrollToTop) {
window.scrollTo({
top: 0,
behavior: 'smooth',
})
}
} else {
// The only way we can arrive here is if there is a cookie and
// it does not match the requested locale in href, in which case
// we call our setLanguageAction for the requested locale which will
// reset the cookie and then redirect to the requested path.
// After setLanguageAction - the cookie and requested locale will
// be back in sync.
const data = new FormData()
data.set('lng', requestedLocale)
data.set('pathname', h)
startTransition(() => {
startProgress()
dispatch(data)
})
}
}
return { navigate, getHref }
}

Here's our custom LangLink component...

i18n/components/lang-link.tsx

'use client'
import React from 'react'
import Link from 'next/link'
import { i18nConfig } from '@/i18n/i18n-config'
import { useLangNavigation } from '@/i18n/hooks/use-lang-navigation'
export interface LangLinkProps {
href: string
lng?: string
forceReload?: boolean
scroll?: boolean
smoothScrollToTop?: boolean
replace?: boolean
useOnPointerDown?: boolean
children: React.ReactNode
[key: string]: any
}
// Strategy based on....
// https://github.com/vercel/next.js/discussions/41934#discussioncomment-8996669
// https://github.com/vercel/react-transition-progress
export const LangLink = React.forwardRef<React.ComponentRef<'a'>, LangLinkProps>(
(
{
href,
children,
lng = i18nConfig.defaultLocale,
forceReload,
smoothScrollToTop,
scroll = true,
replace = false,
useOnPointerDown = false,
...rest
},
ref
) => {
const { navigate, getHref } = useLangNavigation(lng)
const h = getHref(href)
const handleTransition = (
e: React.PointerEvent<HTMLAnchorElement> | React.MouseEvent<HTMLAnchorElement, MouseEvent>
): void => {
e.preventDefault()
navigate({ href, replace, scroll, smoothScrollToTop })
}
if (forceReload === true) {
return (
<a href={h} ref={ref} {...rest}>
{children}
</a>
)
} else {
const handlers = useOnPointerDown
? { onPointerDown: handleTransition }
: { onClick: handleTransition }
return (
<Link
href={h}
scroll={scroll}
replace={replace}
ref={ref}
{...handlers}
// TODO: perhaps we should set both - but set the 'not used' handler
// to () => {} (noop)
// onClick={useOnPointerDown === false ? handleTransition : undefined}
// // NOTE: When LangLink appears asChild in Radix UI we must use onPointerDown
// // and not onClick https://github.com/radix-ui/primitives/issues/1807
// onPointerDown={useOnPointerDown === true ? handleTransition : undefined}
{...rest}
>
{children}
</Link>
)
}
}
)
LangLink.displayName = 'LangLink'

This gives us a lot of control over how we consume and create language-aware links, including being able to send visitors to specific language versions of content. For example:

<Section>
<Container className="prose dark:prose-invert sm:px-[32px]">
<div className="actions py-2">
<LangLink href="/" lng={lng}>
Link to home page in current language
</LangLink>
</div>
<div className="actions py-2">
<LangLink href="/" lng="en">
Link to home page in English
</LangLink>
</div>
<div className="actions py-2">
<LangLink href="/" lng={'es'}>
Link to home page in Spanish
</LangLink>
</div>
</Container>
</Section>

Now for the fun part - loading our translations.

Language Translations

The heart of our dictionary / translations strategy is based on the dictionary example provided in the Next.js docs and also available here in an example repo.

We've taken things a little further providing helper useTranslations functions for both server and client components. For server components, this is nice because we know the locale in use on the server before we render and send our response to the browser (whether via SSR or RSC) and so only the needed translations will be sent to the client (i.e. not the translations for all languages). For our client useTranslation hook, we've created a TranslationsProvider (and context) that makes translations available to all client components, and again, will only take the language dictionary currently in use.

Here they are....

i18n/server/index.ts

// https://github.com/vercel/next.js/tree/canary/examples/app-dir-i18n-routing
import 'server-only'
import { IntlMessageFormat } from 'intl-messageformat'
import type { Locale } from '@/i18n/i18n-config'
// We enumerate all translations here for better linting and typescript support
// We also get the default import for cleaner types
const translations = {
en: () => import('../translations/en.json').then((module) => module.default),
es: () => import('../translations/es.json').then((module) => module.default),
}
export const getTranslations = async (lng: Locale) => translations[lng]?.() ?? translations.en()
export type Translations = Awaited<ReturnType<typeof getTranslations>>
// Server version of useTranslations
export async function useTranslations<T extends keyof Translations>(lng: Locale, namespace: T) {
const translations = await getTranslations(lng)
const namespacedTranslations = translations[namespace]
return {
t: (key: keyof Translations[T], values?: Record<string, any>) => {
const message = namespacedTranslations[key] ?? key
if (typeof message === 'string') {
const formatter = new IntlMessageFormat(message)
return formatter.format(values)
}
return message
},
}
}

i18n/client/translations-provider.tsx

'use client'
import React, { createContext, useContext } from 'react'
// https://formatjs.io/docs/core-concepts/icu-syntax
import { IntlMessageFormat } from 'intl-messageformat'
import type { Translations } from '@/i18n/server/index'
const TranslationsContext = createContext<Translations | null>(null)
export const TranslationsProvider = ({
translations,
children
}: {
translations: Translations
children: React.ReactNode
}) => {
return (
<TranslationsContext.Provider value={translations}>{children}</TranslationsContext.Provider>
)
}
export const useTranslations = <T extends keyof Translations>(
namespace: T
): {
t: (key: keyof Translations[T], values?: Record<string, any>) => string
} => {
const translations = useContext(TranslationsContext)
if (translations == null) {
throw new Error('useTranslations must be used within a TranslationsProvider')
}
// NOTE that source translations in this case are all translations
// for a given language - hence const message = translations[namespace][key] ?? key
// and unlike the server version of t - in @/i18n/server/use-translations
return {
t: (key: keyof Translations[T], values?: Record<string, any>) => {
const message = translations[namespace][key] ?? key
if (typeof message === 'string') {
const formatter = new IntlMessageFormat(message)
return formatter.format(values)
}
return message
}
}
}

Note that for both server and client components, we've imported the IntlMessageFormat class. This allows string interpolation in our translations, following the ICU Intl Message Format including strings with number, date, plural, and select placeholders.

The signature for the two useTranslations functions is slightly different. For the server version we need to pass in the locale. For the client version we know the locale since it came via our provider and context, and so we can simply call the function requesting the required namespace (or subsection of our translations). For example:

'use client'
...
import { useTranslations } from '@/i18n/client'
import { LangLink } from '@/i18n/components/lang-link'
export function SignInForm({ lng }: { lng: string }) {
const { t } = useTranslations('auth')
...

And then we can translate a string or label with....

<div className="flex items-start">
<Checkbox label={t('Remember me')} id="remember" name="remember" />
</div>

What's more - we get TypeScript validation for the translation string. If it's missing from the dictionary, we'll get a TS warning...

Code screenshot showing missing translationsA nice red squiggly line warning us that we're missing a translation string from our language dictionary.

And finally, here's an excerpt from our English dictionary file:

i18n/translations/en.json

{
"common": {
"Home": "Home",
"About": "About",
"Our Mission": "Our Mission",
"Contact Us": "Contact Us",
"Submit": "Submit",
"Cancel": "Cancel",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Sign Out": "Sign Out"
...
},
"auth": {
"Email": "Email",
"emailHelp": "Please enter your email address.",
"Password": "Password",
"Forgot password?": "Forgot password?",
...
},
"register": {
"Sign Up": "Sign Up",
"Name": "Name",
"Already have an account?": "Already have an account?",
...
},
"contact": {
"Contact Us": "Contact Us",
"How can we help?": "How can we help?",
"How to find us": "How to find us",
....
},
}

Whew - that's a lot - but it was worth it. We've implemented a complete i18n system from scratch. We own it and can modify it to suit our needs.

Hope some of this helps and happy translating!