TL;DR: Learn how to implement zero-bundle-size internationalization in the Next.js App Router by leveraging React Server Components and dynamic
app/[lang]sub-path routing. This guide covers building a custom middleware usingnegotiatorand@formatjs/intl-localematcherto parse headers, check locale cookies, and efficiently redirect users without bloating your client bundle.
⚡ Key Takeaways
- Wrap your application in an
app/[lang]directory to natively pass the active locale as a parameter to all dynamic routes. - Use
negotiatorand@formatjs/intl-localematcherin yourmiddleware.tsto parse theAccept-Languageheader and match it against your supported locales. - Prioritize reading a
NEXT_LOCALEcookie in your middleware to respect explicit user language overrides before falling back to browser headers. - Exclude static assets by adding
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt).*)'to your middlewarematcherconfig to drastically reduce edge compute costs. - Load JSON translation dictionaries exclusively on the server using React Server Components (RSC) to keep your client-side bundle size at zero.
You've built a fast, scalable SaaS application in Next.js, achieved product-market fit, and now sales wants to expand into Europe and Latin America. It's time to implement internationalization (i18n).
If you've worked with Next.js in the past, you likely reached for libraries like next-i18next. However, the Next.js App Router completely shifted the paradigm. The built-in i18n configuration in next.config.js is gone. Instead, Next.js now delegates routing and dictionary loading to developers via dynamic routes and middleware.
This architectural shift often leads to a messy transition. You might find yourself fighting hydration mismatches, maintaining middleware tangled in unreadable regex, or dealing with marketing complaints because Google isn't indexing localized pages due to missing hreflang tags.
The solution lies in embracing a native, Server Component-first approach to i18n. In this guide, we'll architect a production-ready localization system in the Next.js App Router. We'll cover dynamic sub-path routing, zero-bundle-size server dictionaries, SEO-perfect metadata, and gracefully handling translations inside Client Components.
Structuring the App for Sub-path Routing
In the App Router, Next.js handles localization via dynamic segments in the file system. By wrapping our entire application inside a [lang] directory, every route naturally receives the active locale as a parameter.
Your directory structure should look like this:
app/
├── [lang]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── pricing/
│ │ └── page.tsx
├── middleware.ts
dictionaries/
├── en.json
├── es.json
To intercept incoming requests and redirect users to the correct locale (e.g., routing /pricing to /en/pricing), we use Next.js Middleware. We'll use two lightweight packages: negotiator (to parse the Accept-Language header) and @formatjs/intl-localematcher (to match the user's preference against our supported locales).
First, install the dependencies:
npm install negotiator @formatjs/intl-localematcher
npm install -D @types/negotiator
Next, configure the middleware.ts file at the root of your project:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { match as matchLocale } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
export const locales = ['en', 'es', 'fr']
export const defaultLocale = 'en'
function getLocale(request: NextRequest): string {
// 1. Check for an explicit language cookie first
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value
if (cookieLocale && locales.includes(cookieLocale)) {
return cookieLocale
}
// 2. Parse the Accept-Language header
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value))
const languages = new Negotiator({ headers: negotiatorHeaders }).languages()
// 3. Match locales
try {
return matchLocale(languages, locales, defaultLocale)
} catch (e) {
return defaultLocale
}
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Check if the pathname already contains a supported locale
const pathnameIsMissingLocale = locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
)
// Redirect if the locale is missing
if (pathnameIsMissingLocale) {
const locale = getLocale(request)
// e.g., incoming request is /pricing
// The new URL becomes /en/pricing
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
}
export const config = {
// Matcher ignores `/_next/` and `/api/` to save edge compute
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt).*)'],
}
Production Note: The
matcherarray in the middleware config is critical. Executing middleware on static assets like images, fonts, or CSS files will drastically increase your edge compute usage and costs. Always exclude_next/staticand public static assets.
Loading Dynamic Dictionaries with Server Components
One of the biggest architectural wins in the Next.js App Router is React Server Components (RSC). Historically, full-stack localization meant shipping massive JSON dictionary files to the browser, severely bloating the client bundle.
When we build full-stack web applications for enterprise clients, frontend performance is non-negotiable. By leveraging RSCs, we securely load our dictionaries on the server. The client only receives the compiled HTML with the translated strings injected.
Let's create our dictionaries and a strongly-typed loader function.
// dictionaries/en.json
{
"navigation": {
"home": "Home",
"pricing": "Pricing"
},
"hero": {
"title": "Scale Your Global SaaS Fast",
"subtitle": "The ultimate platform for international growth."
}
}
// dictionaries/es.json
{
"navigation": {
"home": "Inicio",
"pricing": "Precios"
},
"hero": {
"title": "Escale su SaaS Globalmente Rápido",
"subtitle": "La plataforma definitiva para el crecimiento internacional."
}
}
Now, create the dictionary loader using dynamic imports. Next.js natively caches dynamic import() calls on the server per request, so invoking this function multiple times during a single render pass won't result in duplicate disk reads.
// lib/get-dictionary.ts
import 'server-only'
// Define the type shape based on the default locale
export type Dictionary = typeof import('../dictionaries/en.json')
const dictionaries: Record<string, () => Promise<Dictionary>> = {
en: () => import('../dictionaries/en.json').then((module) => module.default),
es: () => import('../dictionaries/es.json').then((module) => module.default),
fr: () => import('../dictionaries/fr.json').then((module) => module.default),
}
export const getDictionary = async (locale: string): Promise<Dictionary> => {
const loadDictionary = dictionaries[locale] || dictionaries['en']
return loadDictionary()
}
To use this dictionary in a Server Component, simply await the loader:
// app/[lang]/page.tsx
import { getDictionary } from '@/lib/get-dictionary'
export default async function HomePage({
params: { lang },
}: {
params: { lang: string }
}) {
const dict = await getDictionary(lang)
return (
<main>
<h1>{dict.hero.title}</h1>
<p>{dict.hero.subtitle}</p>
</main>
)
}
SEO Mastery: Metadata and Hreflang Tags
Translating your content is only half the battle. If search engines don't understand the relationship between your localized pages, your SaaS won't rank in target markets—and you might even be penalized for duplicate content.
To fix this, we configure generateMetadata to output the correct alternates and hreflang tags. The hreflang tag signals to Google, "This is the Spanish version of the English page you just crawled."
In your layout or specific page components, inject metadata dynamically:
// app/[lang]/layout.tsx
import { Metadata } from 'next'
import { locales } from '@/middleware'
type Props = {
children: React.ReactNode
params: { lang: string }
}
export async function generateMetadata({ params: { lang } }: Props): Promise<Metadata> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://your-saas.com'
// Construct a mapping of all available languages for the current route
const languages = locales.reduce((acc, locale) => {
acc[locale] = `${baseUrl}/${locale}`
return acc
}, {} as Record<string, string>)
return {
metadataBase: new URL(baseUrl),
alternates: {
canonical: `${baseUrl}/${lang}`,
languages: {
...languages,
'x-default': `${baseUrl}/en`, // The fallback for unmatched locales
},
},
}
}
export default function RootLayout({ children, params: { lang } }: Props) {
return (
<html lang={lang}>
<body>{children}</body>
</html>
)
}
Warning: A common SEO mistake is forgetting the
x-defaulttag. Thex-defaulthreflang attribute tells search engines which page to show users when their specific locale is not supported (e.g., a German user visiting your site when you only support English and Spanish).
The Client Component Dilemma: Context vs. Prop Drilling
Because we load dictionaries strictly on the server (enforced via import 'server-only'), passing these translations into "use client" components presents an architectural challenge.
You generally have two approaches:
- The Provider Pattern: Fetch the entire dictionary in the root layout, pass it into a React Context Provider, and use a custom
useTranslation()hook. Trade-off: This completely negates the performance benefits of Server Components, as the entire JSON file is serialized and sent to the browser. - Prop Drilling (Recommended): Fetch the dictionary in the Server Component and pass only the required strings down as props to the interactive Client Components.
Let's look at the recommended prop-drilling pattern. Imagine a client-side checkout button:
// components/CheckoutButton.tsx
'use client'
import { useState } from 'react'
type CheckoutButtonProps = {
text: string
loadingText: string
}
export default function CheckoutButton({ text, loadingText }: CheckoutButtonProps) {
const [isLoading, setIsLoading] = useState(false)
return (
<button
onClick={() => setIsLoading(true)}
disabled={isLoading}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
{isLoading ? loadingText : text}
</button>
)
}
You render it from the Server Component like this:
// app/[lang]/pricing/page.tsx
import { getDictionary } from '@/lib/get-dictionary'
import CheckoutButton from '@/components/CheckoutButton'
export default async function PricingPage({
params: { lang },
}: {
params: { lang: string }
}) {
const dict = await getDictionary(lang)
return (
<section>
<h2>{dict.navigation.pricing}</h2>
{/* We only send the exact strings needed for this component */}
<CheckoutButton
text={dict.checkout.button}
loadingText={dict.checkout.loading}
/>
</section>
)
}
By explicitly passing props, the client bundle remains pristine. The JSON dictionary stays safely on the server.
Advanced UX: Static Generation and Language Switchers
Static Generation with generateStaticParams
Many SaaS marketing pages do not require dynamic server rendering. To statically generate (SSG) your localized routes at build time, utilize generateStaticParams. This ensures your HTML is pre-rendered for every locale, resulting in lightning-fast TTFB (Time to First Byte).
Add this to your root layout or page components:
// app/[lang]/layout.tsx
import { locales } from '@/middleware'
// Generates static paths for /en, /es, /fr at build time
export function generateStaticParams() {
return locales.map((locale) => ({ lang: locale }))
}
Implementing a Language Switcher
Relying solely on the Accept-Language header isn't foolproof. Users traveling abroad or using VPNs often want to override the automatically detected language. When a user explicitly selects a language via a dropdown, we need to save that preference in a cookie (which our middleware will prioritize on their next visit) and update the route.
Here is how you build a robust language switcher Client Component:
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { locales } from '@/middleware'
export default function LanguageSwitcher({ currentLang }: { currentLang: string }) {
const pathname = usePathname()
const router = useRouter()
const switchLanguage = (newLocale: string) => {
// 1. Set the cookie for future visits
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000; SameSite=Lax`
// 2. Replace the current locale in the URL path
if (!pathname) return
const segments = pathname.split('/')
segments[1] = newLocale // segments[0] is an empty string, segments[1] is the locale
// 3. Navigate to the new URL
router.push(segments.join('/'))
}
return (
<select
value={currentLang}
onChange={(e) => switchLanguage(e.target.value)}
className="border p-2 rounded-md bg-white text-gray-900"
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale.toUpperCase()}
</option>
))}
</select>
)
}
This ensures the user's explicit choice is respected globally across your SaaS architecture, preventing the middleware from annoyingly redirecting them back to their browser's default language.
Summary
Implementing i18n in the Next.js App Router requires a solid grasp of server-side data fetching, middleware manipulation, and SEO fundamentals. By structuring your application with [lang] dynamic routes, isolating your dictionaries within Server Components to protect bundle sizes, and rigorously applying hreflang metadata, you can deploy a highly performant, globally scalable SaaS application.
If you are curious about how we structure global release cycles and manage technical debt for complex applications, you can read more about how we build products. Handling i18n at scale is just one piece of the puzzle—maintaining a clean, predictable architecture ensures your team can iterate quickly as your market share grows.
Need help building this in production?
SoftwareCrafting is a full-stack dev agency — we ship fast, scalable React, Next.js, Node.js, React Native & Flutter apps for global clients.
Get a Free ConsultationFrequently Asked Questions
How is i18n different in the Next.js App Router compared to previous versions?
In previous versions, Next.js handled internationalization via built-in configuration in next.config.js. In the App Router, this configuration is removed, and developers must manually handle locale routing and dictionary loading using dynamic route segments (like [lang]) and middleware.
What is the recommended directory structure for i18n in the Next.js App Router?
You should wrap your entire application inside a dynamic locale directory, such as app/[lang]/page.tsx. This structure ensures that every route naturally receives the active locale as a parameter, making it easy to fetch the correct translation dictionary.
How do React Server Components improve i18n performance?
React Server Components allow you to load translation dictionaries entirely on the server, preventing massive JSON files from bloating the client bundle. When delivering full-stack web development services at SoftwareCrafting, we leverage this architecture to ensure the browser only receives lightweight, compiled HTML with injected strings.
How do I automatically detect a user's preferred language in Next.js?
You can use Next.js Middleware combined with lightweight packages like negotiator and @formatjs/intl-localematcher. These tools parse the user's Accept-Language header and match it against your supported locales to redirect them to the correct sub-path.
Why is it important to configure the middleware matcher for Next.js i18n?
Executing middleware on static assets like images, fonts, or CSS files will drastically increase your edge compute usage and hosting costs. You should always configure the matcher array in your middleware.ts file to explicitly exclude paths like _next/static and public assets.
How do I ensure my localized Next.js application is SEO-friendly?
To ensure search engines index your localized pages correctly, you must implement proper hreflang tags and metadata for each dynamic locale route. If you need expert assistance scaling your global SaaS architecture, SoftwareCrafting services can help you implement a production-ready, SEO-perfect localization system.
📎 Full Code on GitHub Gist: The complete
directory-structure.txtfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
