gcampes
Back

Three languages, one weekend: localizing a Next.js App Router site with Next Intl

Sep 25, 2025 09/25/2025

nextjsi18nlocalizationreactreact-intltailwindmdx

Why localization?

I'm Brazilian, I've lived in Germany since 2017, and I work for a company based in the US. My website was English-only. That felt like a missed opportunity.

I also wanted an excuse to migrate off the Pages Router. At work we've been stuck on it for a while, and my personal site was the same. Localization felt like the right scoped project to force the move: App Router + Next Intl, one weekend.


Routing and middleware

There are a few ways to handle locale in URLs: separate domains (gcampes.de), subdomains (de.gcampes.me), or a prefix in the path. I went with Next Intl and prefix-based routing, the simplest option, where the locale appears at the start of every URL: /en/blog, /pt/experience/2022-01-01-seatgeek.

All supported locales live in a single config file:

// i18n/routing.ts
import { defineRouting } from "next-intl/routing";
 
export const locales = ["en", "pt", "de"];
 
export const routing = defineRouting({
  locales,
  defaultLocale: "en",
});

And I use a middleware.ts to make sure requests resolve to the right place:

// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
 
export default createMiddleware(routing);
 
export const config = {
  matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)"
};

The matcher tells Next.js to run the middleware on all routes except API endpoints, internal Next.js paths (_next, _vercel), and static files (anything with a file extension like .css or .png).

This means a bare /blog redirects to /en/blog by default. If the visitor's browser sends an Accept-Language header that includes Portuguese, the middleware redirects to /pt/blog instead.

One more thing on routing: don't prefix links manually. Create a i18n/navigation.ts file that you can import and forget:

// i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
 
export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);

Then use the Link component and the locale prefix is added automatically:

import { Link } from "@/i18n/navigation";
 
<Link href="/">
  {t("home")}
</Link>

Messages (translations)

Translation strings live in plain JSON files, one per locale. Nothing fancy:

{
  "metadata": {
    "title": "Senior Software Engineer in Berlin | gcampes",
    "description": "Gabriel Carvalho de Campes, Senior Software Engineer in Berlin, Germany."
  },
  "navigation": {
    "home": "Home",
    "experience": "Experience"
  },
  "homepage": {
    "experienceDescription": "I have been working as a software engineer for {years} years."
  }
}

Portuguese and German versions mirror this structure. Notice the {years} placeholder in experienceDescription. Next Intl interpolates these at render time, so each locale can place them wherever the grammar requires.


Request config

The request config file tells Next Intl how to resolve the current locale and load the right messages for each server-side render. I found this poorly explained in the docs, so here's what I wish they'd said upfront.

If you place the file at i18n/request.ts, Next Intl picks it up automatically with zero config:

// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale;
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Because I followed the naming convention, next.config.ts just needs createNextIntlPlugin() with no arguments. If you put the file somewhere else, you'd pass the path to createNextIntlPlugin("./your-path/your-file.ts") instead.


Layout and provider

The [locale] layout wraps everything in a NextIntlClientProvider. This is what makes client-side hooks like useRouter and useLocale work.

Since all my translations load server-side via getTranslations, I don't pass messages to the provider. If you use useTranslations in client components, you'll need to.

// app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { routing } from "@/i18n/routing";
 
export default async function LocaleLayout({ children, params }) {
  const { locale } = params;
 
  if (!hasLocale(routing.locales, locale)) throw new Error("Unknown locale");
 
  setRequestLocale(locale);
 
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

After this setup, you can use getTranslations in any server component:

import { getTranslations } from 'next-intl/server';
 
export default async function HomePage() {
  const t = await getTranslations('homepage');
  return <h1>{t('name')}</h1>;
}

For client components, use the useTranslations hook instead. In that case, pass messages to the provider.


Metadata per locale

You also want localized <title> and <meta> tags. Without them, Google indexes everything as English. generateMetadata makes this straightforward:

export async function generateMetadata({ params }) {
  const { locale } = await params;
 
  const t = await getTranslations({ locale, namespace: "metadata" });
 
  return {
    title: t("title"),
    description: t("description"),
    alternates: {
      languages: {
        en: "https://gcampes.me/en",
        pt: "https://gcampes.me/pt",
        de: "https://gcampes.me/de"
      }
    }
  };
}

Rich text with .rich

I almost missed this one. Normally, if you want to style part of a translated string (like making a company name colored), you'd split the translation into pieces and wrap them in JSX manually. With .rich, you put lightweight tags directly in the message string and map them to React components at render time:

// en.json
"experience": {
  "title": "{denomination} {title} at <company>{companyName}</company>"
}
 
// pt.json
"experience": {
  "title": "{title} {denomination} em <company>{companyName}</company>"
}

And render it:

<h1>
  {t.rich("title", {
    denomination: "Senior",
    title: "Software Engineer",
    companyName: "SeatGeek",
    company: (chunks) => <span className="text-brand">{chunks}</span>
  })}
</h1>

Notice how Portuguese flips the order ("Software Engineer Senior" instead of "Senior Software Engineer") and uses "em" instead of "at". The <company> tag stays attached to the company name in both.


Dates with date-fns

For date formatting I used date-fns, since it was already in my stack. Next Intl has a built-in date formatter that might simplify this. For this project, reaching for what I already knew kept the scope tight.

import { format } from "date-fns";
import { ptBR, enUS, de } from "date-fns/locale";
 
function getDateFnsLocale(appLocale) {
  switch (appLocale) {
    case "pt": return ptBR;
    case "de": return de;
    default: return enUS;
  }
}
 
<p>
  {t("fromTo", {
    from: format(new Date("2021-05-01"), "MMMM yyyy", { locale: getDateFnsLocale(locale) }),
    to: format(new Date("2024-08-01"), "MMMM yyyy", { locale: getDateFnsLocale(locale) })
  })}
</p>

Messages handle the grammar ("From {from} to {to}"), date-fns handles the month names.


MDX posts per locale

Each locale gets its own folder:

blog/
  en/
    my-post.mdx
  pt/
    my-post.mdx
  de/
    my-post.mdx

A small helper reads the right file from disk using Node's fs module:

import fs from "fs";
import matter from "gray-matter";
 
export function getPost(locale, slug) {
  const raw = fs.readFileSync(`blog/${locale}/${slug}.mdx`, "utf8");
  return matter(raw);
}

Nothing clever. Same slug, different folder. You can diff the English and Portuguese versions directly.

I considered keeping a single file per post with locale flags in frontmatter, but separate files per locale made diffs cleaner and let me hand off individual translations without sharing the full repo.


Gotchas I hit

Metadata

Always call setRequestLocale(locale) inside generateMetadata. Without it, Next Intl falls back to the default locale, so your German page might end up with English metadata:

import { setRequestLocale } from "next-intl/server";
 
export async function generateMetadata({ params }) {
  const { locale } = await params;
  setRequestLocale(locale);
  // ...
}

Static site generation

By default, the App Router generates pages on demand. If you want pages pre-built at build time (faster loads, works without a server), you need to tell Next.js every valid combination of route parameters using generateStaticParams. For my index route:

export async function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

For routes with multiple dynamic segments like blog posts, you need to generate every combination of locale and slug:

export async function generateStaticParams() {
  const routes: { locale: string; slug: string }[] = [];
 
  routing.locales.forEach((locale) => {
    const posts = getPosts(locale);
    posts.forEach((post) => {
      const { data } = matter(post);
      routes.push({ locale, slug: data.slug });
    });
  });
 
  return routes;
}

Closing

The whole thing took a weekend. Most of that was figuring out request.ts and the generateStaticParams gotchas. The actual Next Intl API is small and stays out of your way once it's wired up.

Honestly the best part is switching to Portuguese and seeing my own site in my first language for the first time.