next-intl v4 is the go-to library for adding internationalization to Next.js App Router projects. Install it with npm install next-intl, configure locale routing in i18n/routing.ts, add a middleware for locale detection, and use getTranslations (Server Components) or useTranslations (Client Components) to render translated text.
32blog.com supports Japanese, English, and Spanish. URLs follow a prefix pattern: /ja/nextjs/slug, /en/nextjs/slug, and /es/nextjs/slug.
This article walks through the full setup — routing config, component-level translation usage, locale switcher, and SEO with hreflang — everything actually running on 32blog.com.
What Is next-intl?
Next.js supports internationalization natively via the i18n option in next.config.js — but only in Pages Router. App Router dropped that option. To add i18n to App Router, you either build your own locale-based routing or use a library like next-intl.
next-intl is an App Router-first i18n library with these key features:
- URL prefix-based locale routing (
/ja/...,/en/...) - Translation hooks that work in both Server and Client Components
- TypeScript support (type-checked translation keys)
- SEO utilities (hreflang helpers)
Installation and File Structure
npm install next-intl
Here's the file structure on 32blog.com:
src/
├── app/
│ ├── [locale]/ # Locale prefix
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── [category]/
│ │ └── [slug]/
│ │ └── page.tsx
│ └── api/
├── i18n/
│ ├── routing.ts # Routing config
│ └── request.ts # Request config
├── messages/
│ ├── ja.json # Japanese translations
│ ├── en.json # English translations
│ └── es.json # Spanish translations
└── middleware.ts # Locale detection and redirects
Routing Configuration
Start with src/i18n/routing.ts to define supported locales and the default. See the next-intl routing docs for all available options.
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["ja", "en", "es"],
defaultLocale: "ja",
// "always" means /ja/..., /en/..., /es/... all have explicit prefixes
localePrefix: "always",
});
Next, create middleware.ts. This intercepts requests, detects the locale, and redirects to the correct URL. The middleware docs cover all configuration options.
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
// Apply middleware to these paths (exclude static files and internal APIs)
matcher: [
"/",
"/(ja|en|es)/:path*",
"/((?!api|_next|_vercel|.*\\..*).*)",
],
};
Request Configuration
src/i18n/request.ts defines how Server Components load translations for the current request.
// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
// Get locale from the URL
let locale = await requestLocale;
// Fall back to default locale if unsupported locale detected
if (!locale || !routing.locales.includes(locale as "ja" | "en" | "es")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
Creating Translation Files
Define translation text in messages/ja.json:
{
"nav": {
"home": "ホーム",
"about": "このブログについて",
"articles": "記事一覧"
},
"home": {
"hero": {
"title": "ググって最後にたどり着くページ",
"subtitle": "エラー解決・技術Tips・実装例を実体験ベースで書いています"
},
"latestArticles": "新着記事",
"allArticles": "すべての記事を見る"
},
"article": {
"publishedAt": "公開日",
"updatedAt": "更新日",
"readingTime": "{minutes}分で読めます",
"tableOfContents": "目次",
"relatedArticles": "関連記事"
}
}
And in messages/en.json:
{
"nav": {
"home": "Home",
"about": "About",
"articles": "Articles"
},
"home": {
"hero": {
"title": "The last page you land on after Googling",
"subtitle": "Error fixes, tech tips, and real-world implementations based on actual experience"
},
"latestArticles": "Latest Articles",
"allArticles": "View all articles"
},
"article": {
"publishedAt": "Published",
"updatedAt": "Updated",
"readingTime": "{minutes} min read",
"tableOfContents": "Table of Contents",
"relatedArticles": "Related Articles"
}
}
Integrating into the Layout
Set up the next-intl provider in app/[locale]/layout.tsx:
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Return 404 for unsupported locales
if (!routing.locales.includes(locale as "ja" | "en" | "es")) {
notFound();
}
// Fetch translation messages from Server Component
const messages = await getMessages();
return (
<html lang={locale}>
<body>
{/* Provider lets Client Components access translations */}
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Using Translations in Server Components
// app/[locale]/page.tsx
import { getTranslations } from "next-intl/server";
// Server Components use getTranslations
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "home" });
return (
<main>
<section>
<h1>{t("hero.title")}</h1>
<p>{t("hero.subtitle")}</p>
</section>
</main>
);
}
Using Translations in Client Components
// components/NavMenu.tsx
"use client";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
export function NavMenu() {
// Client Components use useTranslations
const t = useTranslations("nav");
return (
<nav>
{/* next-intl's Link automatically prepends the current locale */}
<Link href="/">{t("home")}</Link>
<Link href="/about">{t("about")}</Link>
</nav>
);
}
Using next-intl's Link component means locale prefixes get added automatically. Writing /about will link to /ja/about for Japanese users and /en/about for English users — without any extra code.
Building the Locale Switcher
Here's the locale switcher that lets users toggle between languages:
// components/LocaleSwitcher.tsx
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "@/i18n/routing";
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: string) => {
// Stay on the same path but switch locale
router.replace(pathname, { locale: newLocale });
};
return (
<div>
<button
onClick={() => switchLocale("ja")}
aria-current={locale === "ja" ? "true" : undefined}
>
日本語
</button>
<button
onClick={() => switchLocale("en")}
aria-current={locale === "en" ? "true" : undefined}
>
English
</button>
</div>
);
}
Pass a locale option to router.replace and next-intl handles the path transformation. If a user is reading /ja/nextjs/hydration-error-fix and clicks "English," they'll land on /en/nextjs/hydration-error-fix.
TypeScript Integration for Type-Safe Keys
next-intl v4 supports TypeScript-checked translation keys via module augmentation on the AppConfig interface. Typos in key names become build errors.
// global.d.ts
import { routing } from "@/i18n/routing";
import en from "../messages/en.json";
declare module "next-intl" {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof en;
}
}
This registers three things at once: Locale strictly types all locale strings (so "fr" would be a compile error if it's not in your routing config), and Messages enables autocomplete and type checking for translation keys.
// ❌ Key doesn't exist — TypeScript error
const title = t("nav.hme"); // Error: Argument of type '"hme"' is not assignable...
// ✅ Correct key — no error
const title = t("nav.home"); // OK
With strict Locale typing, you can also use next-intl's hasLocale() helper to narrow unknown strings:
import { hasLocale } from "next-intl";
// locale is typed as "ja" | "en" | "es" after narrowing
if (hasLocale(routing.locales, locale)) {
// TypeScript knows locale is valid here
}
SEO: Setting Up hreflang
Multilingual sites need hreflang tags so Google can correctly associate language versions of the same page. next-intl makes this straightforward via Next.js metadata.
// app/[locale]/[category]/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; category: string; slug: string }>;
}): Promise<Metadata> {
const { locale, category, slug } = await params;
const post = await getPost(category, slug, locale);
const baseUrl = "https://32blog.com";
return {
title: post.title,
description: post.description,
alternates: {
canonical: `${baseUrl}/${locale}/${category}/${slug}`,
languages: {
// hreflang tags
"ja": `${baseUrl}/ja/${category}/${slug}`,
"en": `${baseUrl}/en/${category}/${slug}`,
"es": `${baseUrl}/es/${category}/${slug}`,
"x-default": `${baseUrl}/ja/${category}/${slug}`,
},
},
};
}
This automatically generates <link rel="alternate" hreflang="ja" href="..."> tags in each page's <head>. Google can then correctly understand that the language versions are translations of the same content, not duplicate pages.
FAQ
Does next-intl work with Next.js 16?
Yes. next-intl v4 supports Next.js 13 through 16. One caveat: Next.js 16's use cache directive doesn't yet work seamlessly with getTranslations() because it internally reads from headers(). The existing setRequestLocale pattern continues to work.
What's the difference between getTranslations and useTranslations?
getTranslations is an async function for Server Components. useTranslations is a React hook for Client Components. Using the wrong one in the wrong context causes a runtime error.
Can I use next-intl without the [locale] URL prefix?
Yes. Set localePrefix: "as-needed" in your routing config, and only non-default locales will have a prefix. Or use localePrefix: "never" to rely on domain-based or cookie-based detection instead.
How do I add a third (or fourth) language later?
Add the locale to routing.ts, create its messages/{locale}.json file, and update the middleware matcher regex. next-intl picks it up automatically — no component changes needed.
Does next-intl increase the client-side bundle size?
next-intl itself is lightweight (~2 kB gzipped for the client runtime). The main bundle impact comes from the translation JSON you pass to NextIntlClientProvider. Splitting messages by namespace reduces this.
How does next-intl handle SEO for multilingual sites?
It provides hreflang utilities through Next.js metadata API. Combined with locale-prefixed URLs and proper canonical tags, Google correctly associates all language versions of each page.
Should I use next-intl or next-i18next?
next-i18next is Pages Router-focused and wraps i18next. For App Router projects, next-intl is purpose-built and has first-class support for Server Components, making it the better choice for new projects.
Wrapping Up
Here's a summary of the key files in the next-intl v4 setup:
| File | Role |
|---|---|
i18n/routing.ts | Define supported locales and default locale |
middleware.ts | Detect locale from requests and redirect |
i18n/request.ts | Configure how Server Components load messages |
messages/ja.json | Japanese translation strings |
app/[locale]/layout.tsx | Set up NextIntlClientProvider |
global.d.ts | Register AppConfig for type-safe keys |
The main thing that trips people up: use getTranslations in Server Components and useTranslations in Client Components. Similarly, use next-intl's Link instead of Next.js's built-in Link — otherwise locale prefixes won't be added automatically.
Once you have the basics running, check out the common pitfalls with next-intl and i18n — things like hydration mismatches from locale-dependent formatting and translation key organization patterns that break at scale. If you're working with Server Components more broadly, the Next.js SSR guide covering Server Components covers the rendering model that next-intl builds on.
32blog.com runs this setup across all pages serving thousands of pageviews. The App Router version of next-intl has been solid and stable.