Internationalized Routing Beyond the Basics in Next.js

Next.js has supported internationalized routing since version 10, but there is more to building a localized app than registering a few locales. Routing handles the URLs; the harder problems are serving translated content efficiently and keeping the developer experience pleasant. This article walks through the built-in routing features, a lightweight approach to translations, and a strategy for scaling without shipping every dictionary to every client.

Declaring Locales in Configuration

To tell Next.js that your app serves multiple languages, add an i18n block to next.config.js. The configuration defaults are minimal:

/** @type {import('next').NextConfig} */

module.exports = {
  reactStrictMode: true,
  i18n: {
    locales: ['en', 'gc'],
    defaultLocale: 'en',
  }
}

The i18n object has only two required properties:

  • locales — an array of strings listing every locale your app supports.
  • defaultLocale — the locale used at the root when no other preference is found.

These values directly influence generated routes, so stick to lowercase locale or country codes that will read cleanly in a URL.

Once multiple locales are configured, every route exists for every locale, and Next.js treats them as the same page. Navigation needs to be explicit when you want a specific language. The Link component accepts a locale prop; without it, Next.js falls back to the browser’s Accept-Language header.

<Link href="/" locale="de"><a>Home page in German</a></Link>

For a reusable switch that always sends the user to their currently selected locale, pull locale from the useRouter hook and build links from there:

import type { FC } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'

const Anchor: FC<{ href: string }> = ({ href, children }) => {
  const { locale } = useRouter()

  return (
    <Link href={href} locale={locale}>
      <a>{children}</a>
    </Link>
  )
}

At this point, your app will detect the user’s preferred language from the request header, route them based on that preference, and fall back to the default when there is no match. The missing piece is translating page content.

Building a Translation Dictionary

Translations ultimately reduce to a JSON object mapping locales, keys, and values. How you create that object — via a translation management service or otherwise — matters less than the shape itself. For an English and Portuguese app, a minimal dictionary looks like this:

module.exports = {
  en: {
    hello: 'hello world'
  },
  pt: {
    hello: 'oi mundo'
  }
}

From there, a custom hook can give pages a translate method. The hook pulls the current locale, available locales, and the default from useRouter, validates the current locale, and returns a function that looks up keys in the dictionary, falling back to the key itself when no value exists:

import { useRouter } from 'next/router'
import dictionary from './dictionary'

export const useTranslation = () => {
  const { locales = [], defaultLocale, ...nextRouter} = useRouter()
  const locale = locales.includes(nextRouter.locale || '')
    ? nextRouter.locale
    : defaultLocale
  
  return {
    translate: (term) => {
      const translation = dictionary[locale][term]

      return Boolean(translation) ? translation : term
    }
  }
}

This setup covers basic needs, but it deliberately skips advanced features like interpolation, pluralization, and gender-based agreement. Those can be added when the need actually shows up. However, the bigger issue with this approach is that it ignores the isomorphic nature of Next.js.

Scaling Concerns: Dictionaries Get Heavy

Managing translation actions is predictable. The real bottleneck is the payload: shipping a complete dictionary for every language to every browser gets bloated as the number of languages grows. Users have to download unused data or, worse, make extra round-trips to fetch new keys when they switch languages mid-session.

The ideal behavior is that all translations needed for a route — and every state it can render — are available as soon as that route loads. That points to a key requirement: split the dictionaries by page, not by global scope.

Server-Side Translation Elimination

Next.js pages accept a getStaticProps function, and it is an ideal place to pre-filter translations. The goal is threefold:

  1. Send as little data to the client as possible.
  2. Avoid extra requests triggered by user interactions.
  3. Deliver the first render already translated.

Create a helper utility that takes a translation key and the full dictionary, then turns it into a list of objects, one per locale:

export function ssrI18n(key, dictionary) {
  return Object.keys(dictionary)
    .reduce((keySet, locale) => {
      keySet[locale] = (dictionary[locale as keyof typeof dictionary][key])
      return keySet
    , {})
}

The helper walks each locale key in the dictionary, building a flat array where every entry names the locale and its value for that specific term:

{
  'hello': {
    'en': 'Hello World',
    'pt': 'Oi Mundo',
    'de': 'Hallo Welt'
  }
}

In the page, getStaticProps imports the full dictionary and passes only the pre-filtered terms to the component:

import { ssrI18n } from '../utils/ssrI18n'
import { DICTIONARY } from '../dictionary'
import { useRouter } from 'next/router'

const Home = ({ hello }) => {
  const router = useRouter()
  const i18nLocale = getLocale(router)

  return (
    <h1 className={styles.title}>
      {hello[i18nLocale]}
    </h1>
  )
}

export const getStaticProps = async () => ({
  props: {
    hello: ssrI18n('hello', DICTIONARY),
    // add another entry to each translation key
  }
})

With that in place, each page receives only the translations it needs, in every supported language. Locale switching is instantaneous because nothing needs to be fetched at runtime.

Type-Safe Translations

The manual setup works, but it suffers from a classic problem: mistyped translation keys. Runtime errors are not caught until a user hits a broken path. TypeScript can catch those typos at compile time, though the boilerplate above makes that non-trivial to wire up manually.

To skip the bootstrap entirely and get autocompletion and type checking out of the box, try next-g11n, a small library that implements exactly the pattern described here while adding a layer of type safety.

Where to Go Next

Next.js’ built-in internationalized routing takes care of URL structures and locale detection with very little configuration. For well-performing, maintainable localization, the next steps are cutting dictionary payloads via getStaticProps and adding compile-time guarantees. For more context, review Next.js Internationalized Routing documentation.