32blog.com supports both Japanese and English. URLs follow a prefix pattern: /ja/nextjs/slug for Japanese and /en/nextjs/slug for English.
The library making this work is next-intl v4. This article walks through the full setup — routing config, component-level translation usage, locale switcher, and MDX article language switching — everything I actually use 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 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 I use 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
└── middleware.ts # Locale detection and redirects
Routing Configuration
Start with src/i18n/routing.ts to define supported locales and the default.
// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["ja", "en"],
defaultLocale: "ja",
// "always" means /ja/... and /en/... both have explicit prefixes
localePrefix: "always",
});
Next, create middleware.ts. This intercepts requests, detects the locale, and redirects to the correct URL.
// 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)/: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")) {
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")) {
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. Typos in key names become build errors.
// types/global.d.ts
import en from "../messages/en.json";
type Messages = typeof en;
declare global {
interface IntlMessages extends Messages {}
}
With this in place:
// ❌ 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
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}`,
"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 two versions are translations of the same content, not duplicate pages.
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 |
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.
32blog.com runs this setup across all pages serving thousands of pageviews. The App Router version of next-intl has been solid and stable.