# What You’ll Build#
This guide shows a production-ready approach to Next.js i18n App Router with locale-prefixed URLs, language detection, SEO-safe metadata, and scalable translation workflows. It’s written for teams shipping real products, where caching, dynamic routes, and content operations matter more than hello-world demos.
You’ll learn how to structure routes like /en/blog/... and /hr/blog/..., how to detect and persist language, and how to generate correct canonical and hreflang URLs for Google.
You’ll also see three translation workflow options, from JSON files to headless CMS to localization platforms, with decision criteria and pitfalls.
# Prerequisites and Recommended Stack#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 14.2 or newer | App Router stable patterns, metadata API, middleware |
| Node.js | 18 or newer | LTS recommended |
| Hosting | Vercel or equivalent | CDN caching makes i18n bugs more visible |
| i18n library | next-intl recommended | Not required, but reduces custom plumbing |
| Content source | JSON, CMS, or localization service | Choose based on content volume and team size |
If SEO is a priority, read these two guides first to align expectations and avoid common mistakes: Why Next.js for SEO and SEO for developers.
# Localized Routing in the App Router#
The App Router does not use the old i18n config from Pages Router to automatically generate locale routes. In practice, you implement localization using a top-level locale segment.
The most maintainable pattern is:
/en/...for English/hr/...for Croatian- optionally
/de/...etc.
That gives you:
- clear separation for caching and indexing
- stable, shareable URLs
- easy
hreflanggeneration
Folder Structure#
A typical structure:
app/[locale]/layout.tsxfor locale-aware layoutapp/[locale]/page.tsxfor locale homeapp/[locale]/blog/[slug]/page.tsxfor localized blog postsmiddleware.tsto redirect users from/to the best locale
This also scales to nested segments like app/[locale]/(marketing)/... and app/[locale]/(app)/... without mixing languages.
Defining Supported Locales#
Create a single source of truth in src/i18n/config.ts:
export const locales = ["en", "hr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export function isLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}This reduces errors where one part of the app thinks you support en-GB and another thinks it’s just en.
Locale-Aware Layout#
In app/[locale]/layout.tsx, validate the locale early. If it’s unsupported, return 404 to avoid indexing garbage URLs.
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { isLocale, type Locale } from "@/i18n/config";
export default function LocaleLayout(props: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
return props.params.then(({ locale }) => {
if (!isLocale(locale)) notFound();
return props.children;
});
}This ensures only /en and /hr exist from the router’s perspective.
🎯 Key Takeaway: Locale-prefixed routes are the simplest way to keep caching, SEO, and analytics clean, because the locale is part of the URL and not inferred from headers.
# Language Detection: Redirects, Cookies, and UX#
Language detection is where many i18n implementations break SEO and caching. The goal is:
- Users landing on
/should be routed to the right locale - Search engines should still be able to crawl every locale URL deterministically
- Returning users should stay on their chosen locale
Detection Priority Order#
A practical priority order:
- 1Explicit locale in URL, like
/hr/...always wins - 2Locale cookie from a user’s manual selection
- 3
Accept-Languageheader for first-time visitors - 4Fallback to default locale
This matches user intent and avoids redirect loops.
Middleware for Redirecting / to a Locale#
Add middleware.ts and only redirect when there is no locale prefix. Avoid redirecting locale-prefixed URLs because that creates unnecessary hops and can confuse crawlers.
import { NextRequest, NextResponse } from "next/server";
import { defaultLocale, isLocale, locales } from "@/i18n/config";
function getPreferredLocale(req: NextRequest) {
const cookieLocale = req.cookies.get("locale")?.value;
if (cookieLocale && isLocale(cookieLocale)) return cookieLocale;
const header = req.headers.get("accept-language") || "";
const first = header.split(",")[0]?.trim().slice(0, 2);
if (first && isLocale(first)) return first;
return defaultLocale;
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const pathLocale = pathname.split("/")[1];
if (pathLocale && isLocale(pathLocale)) return NextResponse.next();
if (pathname === "/" || pathname === "") {
const locale = getPreferredLocale(req);
const url = req.nextUrl.clone();
url.pathname = `/${locale}`;
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next|api|.*\\..*).*)"],
};Persisting User Choice#
When a user switches languages, set a cookie explicitly. This prevents flipping back and forth based on browser headers.
Example API route or server action that sets locale=hr for 1 year is enough. If you prefer no cookies, keep language switching purely URL-based, but expect slightly worse UX for repeated visits to /.
⚠️ Warning: Do not vary HTML content by
Accept-Languageon the same URL. CDNs commonly cache by URL, not by header, so you can serve the wrong language to the next user and confuse Google’s indexing.
# Dynamic Segments: Slugs, IDs, and Translated URLs#
Dynamic segments are where i18n meets content strategy. You have three common options:
| Strategy | Example URL | Pros | Cons |
|---|---|---|---|
| Stable slug across locales | /en/blog/nextjs-i18n and /hr/blog/nextjs-i18n | Simple mapping, fewer redirects | Not ideal for local-language SEO |
| Fully translated slugs | /en/blog/nextjs-i18n and /hr/blog/nextjs-i18n-app-router | Best local SEO, better CTR | Requires slug mapping per locale |
| ID-based routing | /en/blog/12345 | No slug collisions, easy CMS | Worst for SEO and UX unless paired with slug |
For marketing pages and blog posts, translated slugs usually win if you can maintain the mapping. For user-generated content, stable slugs or IDs are safer.
Implementing Localized Blog Routes#
A common pattern is to fetch content by locale plus slug:
locale = en,slug = nextjs-i18n-app-routerlocale = hr,slug = nextjs-i18n-app-router
Or if slugs are translated:
locale = hr,slug = next-js-i18n-aplikacijski-router
Your data model needs a clear unique key. In CMS setups, that’s typically a document ID with localized fields.
Generating Static Params per Locale#
If you statically generate blog pages, ensure you generate params for every locale.
import { locales } from "@/i18n/config";
export async function generateStaticParams() {
const posts = await fetch("https://example.com/api/posts").then((r) => r.json());
return locales.flatMap((locale) =>
posts
.filter((p: any) => p.locales.includes(locale))
.map((p: any) => ({ locale, slug: p.slug[locale] || p.slug.default }))
);
}This avoids the classic bug where English pages build but other locales 404 in production.
# Metadata and SEO: Canonicals, hreflang, and Indexing Signals#
SEO for i18n is mostly about clarity. Search engines need:
- one canonical per locale page
- alternate language links via
hreflang - consistent internal linking within each locale
- no accidental duplicates created by parameters, cookies, or header-based variations
If you want the broader SEO context in one place, reference SEO for developers alongside this guide.
Canonical URL Rules for Localized Pages#
A good default:
/en/blog/postcanonical is itself/hr/blog/postcanonical is itself- never canonicalize all locales to one language homepage
Canonical tags are for duplicate content, not for “primary language preference”. If you canonicalize Croatian to English, you’re telling Google to ignore the Croatian URL.
hreflang Alternates with x-default#
For each page, output alternates for every locale and an x-default that points to a language selector or the default locale route. Many teams set x-default to /en, which is acceptable if English is your true default.
In App Router, implement generateMetadata in each route or in a shared helper.
import type { Metadata } from "next";
import { locales, defaultLocale, type Locale } from "@/i18n/config";
const siteUrl = "https://samioda.com";
export async function generateMetadata(props: {
params: Promise<{ locale: Locale; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await props.params;
const canonical = `${siteUrl}/${locale}/blog/${slug}`;
const languages = Object.fromEntries(
locales.map((l) => [l, `${siteUrl}/${l}/blog/${slug}`])
);
return {
alternates: {
canonical,
languages: {
...languages,
"x-default": `${siteUrl}/${defaultLocale}/blog/${slug}`,
},
},
};
}This produces consistent alternates per page and avoids manual mistakes.
Localized Titles and Descriptions#
If your metadata is translated, ensure it comes from the same source as your content and is not missing in some locales. Missing metadata often leads to fallback titles like “Blog” which reduces CTR.
A practical rule:
- For each locale, require
title,description, andog:titleequivalents - If missing, fail build in CI for marketing content
Sitemap and Robots Considerations#
Make sure your sitemap includes all locale URLs. If you generate sitemaps dynamically, include the locale segment in every entry.
If you exclude non-default locales from sitemap, Google will still find them via links, but indexing is slower and less predictable.
ℹ️ Note: If you redirect
/to a locale, keep the redirect stable and avoid changing it frequently. Google treats frequent redirect behavior changes as a signal of instability, which can slow crawling and recrawling.
# Translation Management: JSON, CMS, or Localization Platforms#
Translation is not just “where strings live”. It affects release cadence, who can edit content, and how you prevent broken layouts.
Here are three workable workflows, with a practical decision table.
| Workflow | Best for | Typical team | Pros | Cons |
|---|---|---|---|---|
| JSON dictionaries in repo | UI strings, small sites | Engineers | Fast, versioned, easy review | Non-dev edits are painful, larger diffs |
| Headless CMS localized fields | Marketing pages, blogs | Marketing plus dev | Editorial workflow, previews, structured content | Needs governance, content model design |
| Localization platform with sync | Apps with frequent releases | Product teams | Translation memory, QA checks, automation | Extra cost, integration complexity |
If you’re evaluating CMS options for localized content modeling, use this comparison as a baseline: Headless CMS comparison 2026.
Option 1: JSON Dictionaries for UI Strings#
For product UI, JSON dictionaries are often the most reliable. They are versioned, reviewed, and deployed together with code.
A minimal example:
{
"nav.home": "Home",
"nav.blog": "Blog",
"cta.bookCall": "Book a call"
}Keep these rules:
- Use stable keys, not English text as keys
- Validate missing keys in CI
- Keep UI text out of CMS if you need strict release control
Option 2: CMS for Marketing Pages and Blog Content#
CMS is the right tool when content changes weekly, not per release. For i18n, model content with:
- a base document ID
- localized fields per locale
- slug per locale if you translate URLs
- localized SEO fields
Example content shape:
| Field | Type | Localized | Notes |
|---|---|---|---|
| id | string | no | Stable identifier |
| slug | string | yes | Used for localized routing |
| title | string | yes | Display and SEO |
| description | string | yes | Meta description |
| body | rich text | yes | Main content |
| canonicalOverride | string | yes | Rarely needed, but useful |
This avoids “multiple documents per language” chaos where editors accidentally unpublish one locale.
Option 3: Localization Platforms for Scale#
If you ship to 5 plus locales or release weekly, localization platforms reduce churn via:
- translation memory
- string reuse
- glossary enforcement
- automated checks for missing variables and placeholders
The key is to integrate them with CI so missing translations fail before production.
# Caching Pitfalls: CDN, Next Cache, and Locale Variants#
Caching and i18n issues are responsible for many “random language” production incidents.
The Core Rule: Cache Must Vary by URL, Not Headers#
If /pricing renders in English for one user and Croatian for another, but the URL is the same, your CDN may cache the first response and serve it to everyone.
Locale-prefixed URLs avoid this. /en/pricing and /hr/pricing are different cache keys automatically.
Common Caching Mistakes and Fixes#
| Problem | Symptom | Root cause | Fix |
|---|---|---|---|
| Wrong language served | Users see mixed languages | Cache keyed only by path without locale | Use locale in URL, avoid header-based rendering |
| Redirect loop | / keeps redirecting | Cookie vs URL mismatch | URL locale wins, only redirect on non-locale paths |
| Stale translations | New copy not visible | ISR or fetch cache not invalidated | Tag-based revalidation per locale and content type |
| Duplicate indexing | Google finds multiple variants | Query params or trailing slash variants | Normalize URLs, set canonicals, avoid indexing params |
Revalidation by Locale#
If you revalidate content, include the locale in your cache tags. Otherwise, revalidating English could accidentally evict Croatian or vice versa depending on your tagging strategy.
A practical naming scheme:
post:123:enpost:123:hr
Keep tags deterministic and short to avoid operational confusion.
💡 Tip: For multilingual marketing sites, prefer locale-prefixed routes plus static generation for the majority of pages. You get faster TTFB and fewer cache edge cases than fully dynamic, header-driven rendering.
# Canonical URL and Duplicate Content Pitfalls#
i18n increases the number of URLs, so small inconsistencies multiply quickly.
Pitfall 1: Canonical Always Points to Default Locale#
This is common when teams hardcode canonicals in a shared component. The result is that non-default locales may never rank because you told Google they are duplicates.
Fix: compute canonical from params.locale and route params every time.
Pitfall 2: hreflang Missing on Dynamic Pages#
If your blog uses dynamic segments, you may have hreflang on category pages but not on post pages.
Fix: centralize alternates generation and call it from every route that should be indexed.
Pitfall 3: Translating Content Without Translating Internal Links#
If English content links to /en/contact, the Croatian version should link to /hr/contact. Otherwise, you leak users across locales and send mixed signals to crawlers.
Fix: implement a locale-aware link helper and enforce it in content tooling. In CMS rich text, store references to internal pages and resolve them per locale.
Pitfall 4: Parameterized URLs Indexed as Separate Pages#
UTM parameters and filters can create duplicate pages across languages.
Fix:
- keep canonicals clean
- configure analytics to ignore irrelevant parameters
- avoid linking to parameterized URLs internally
For broader SEO hygiene that intersects with i18n, align with the practices in Why Next.js for SEO.
# A Practical Content Workflow That Doesn’t Break Builds#
Here’s a workflow that works well for teams with both engineers and marketers:
- 1UI strings live in JSON dictionaries in the repo.
- 2Marketing pages and blog content live in a headless CMS with localized fields.
- 3Slug mapping is enforced in the CMS. If a locale slug is missing, the page is not published for that locale.
- 4CI checks ensure:
- every published page has
titleanddescriptionper locale - no unsupported locales are referenced
- sitemap contains all published locale URLs
That prevents the two most expensive failures:
- broken navigation across locales
- accidental indexing of incomplete translations
# Key Takeaways#
- Use locale-prefixed routing in the App Router, because it keeps caching and SEO deterministic and avoids header-based content variation.
- Implement language detection only for non-locale paths like
/, with a clear priority order: URL, cookie, header, default. - Generate correct SEO metadata per locale: self-referencing canonicals, full
hreflangalternates, and anx-defaultentry. - Choose translation workflows by content type: JSON for UI strings, CMS for editorial content, and localization platforms for high-frequency releases.
- Treat dynamic segments as a content-model problem: decide early whether slugs are stable, translated, or ID-based, and generate static params for every locale.
- Prevent production incidents by making caches vary by URL and by tagging revalidation per locale and content entity.
# Conclusion#
A solid Next.js i18n App Router setup is less about translating strings and more about building predictable URLs, stable detection, and SEO signals that scale to dozens or thousands of pages. When you get routing, canonicals, and caching right, you avoid the most common multilingual failures: wrong-language responses, duplicate indexing, and unmaintainable slug logic.
If you want Samioda to implement multilingual routing, SEO-safe metadata, and a content workflow that your team can run without developer bottlenecks, reach out and we’ll scope an i18n architecture that fits your locales, CMS, and release process.
FAQ
More in Web Development
All →React Forms at Scale: React Hook Form + Zod Patterns for Complex Products
React forms best practices for large apps using React Hook Form and Zod: schema-first validation, reusable fields, async checks, multi-step flows, performance, accessibility, and server/API integration patterns.
Implementing Stripe Subscriptions in Next.js: Billing Portal, Webhooks, and Entitlements
A production-ready guide to Next.js Stripe subscriptions: plans and trials, Billing Portal, webhook verification, idempotent processing, entitlement mapping, and Stripe CLI testing.
Next.js Caching Strategies Explained: SSR, SSG, ISR, Route Cache, and SWR
A practical guide to Next.js caching strategies in the App Router era — how SSR, SSG, ISR, the Route Cache, Data Cache, and SWR fit together, with decision tables, code examples, and common pitfalls like stale auth and tenant data.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js App Router Migration Checklist (From Pages Router) + Common Pitfalls
A practical, step-by-step Next.js App Router migration plan from Pages Router, including a checklist for routing, data fetching, SEO metadata, deployment, and a troubleshooting guide for common pitfalls.
Next.js Caching Strategies Explained: SSR, SSG, ISR, Route Cache, and SWR
A practical guide to Next.js caching strategies in the App Router era — how SSR, SSG, ISR, the Route Cache, Data Cache, and SWR fit together, with decision tables, code examples, and common pitfalls like stale auth and tenant data.
Why Next.js Is the Best Framework for SEO in 2026
Learn why Next.js dominates SEO performance in 2026. Server-side rendering, Core Web Vitals, structured data, and real performance comparisons.