Multilingualism in Modern Web Apps: Integrating next-intl, SEO-Friendly Slugs, and Robust Architecture
Expanding into international markets requires more than just quality translation—it demands a solid technical foundation. Search engines need to clearly understand which language version to index, and users expect an instant response in their native language via user-friendly URLs (e.g., /en/products or /uk/products).
Today, we'll break down how to build such a system from scratch. Our toolkit: Next.js 16 and the next-intl library. It's the industry standard that works perfectly with the App Router, supports Server Components, and provides strict typing for translation keys.
Step 1: Setting up the Foundation
First, let's add next-intl to our project using a fast and reliable package manager:
Bash
pnpm add next-intl
This is enough to start building the foundation. But before we write any configurations, we need to bring perfect order to our file structure.
Step 2: Route Groups Architecture — Divide and Conquer
The biggest mistake beginners make is dumping all pages into the src/app root directory and trying to wrap the entire application in one giant provider. We will take the pro path and use Route Groups (folders in parentheses), which allow us to logically separate the application without affecting the URL structure.
Our src/app structure will look like this:
Plaintext
src/
└── app/
├── (admin)/
│ ├── layout.tsx
│ └── page.tsx
├── (locale)/
│ └── [locale]/
│ ├── layout.tsx
│ └── page.tsx
├── (root)/
│ ├── layout.tsx
│ └── page.tsx
└── proxy.ts
Why this specific distribution, and what should be inside?
An Important Rule: Each of these groups is a completely isolated environment. This means each folder needs its own layout.tsx (where you define <html>, <body>, and specific wrappers) and a base page.tsx.
1. The (locale) Folder: The Client-Side
This is the heart of our multilingual site. Inside, we create a dynamic [locale] segment. All pages that require SEO indexing and a language prefix in the URL (e.g., the homepage, product catalog, blog) will live here.
Layout: Here, we initialize
next-intldictionaries, connect global styles for the public-facing side, and include the header (with the language switcher) and the footer.
2. The (admin) Folder: Control Panel
The admin area is a completely different world. It often doesn't need a language prefix in the URL (the admin can work via /admin), it has a different design, different providers (e.g., dashboard-specific), and zero SEO requirements.
Layout: Contains the admin sidebar, authentication providers, and access control checks, without being overloaded by the site-wide client dictionaries.
3. The (root) Folder: Technical Zone
This is where routes that need to work without a language prefix go. This can include error pages, system webhooks, or API endpoints.
Layout: As lightweight and basic as possible, just enough so Next.js can render the page without "missing root tag" errors.
Step 3: Next-Gen Routing
Previously, for intercepting requests and determining the user's language, we would have used middleware.ts. However, in the Next.js 16 architecture, the approach has changed: now, this functionality (as well as Auth) is handled by proxy.ts, which works faster and integrates better with the framework's latest server-side capabilities. It will intercept the incoming request, analyze browser headers (Accept-Language), and redirect the user to the correct [locale] route.
Step 4: Breaking the Code Down to Atoms
Let's look at how to configure each of our isolated folders so that Next.js, next-intl, and search bots work as a single mechanism.
1. Technical Zone: src/app/(root)
What happens when a user simply enters yourdomain.com without specifying a language? To avoid duplicate content (SEO's greatest enemy), we must instantly redirect them to the default language version (e.g., /uk).
Step 4: Breaking the code down into atoms
Let's look at how to configure each of our isolated directories so that Next.js, next-intl, and search bots work as a single mechanism.
1. The Technical Zone: src/app/(root)
What happens when a user simply enters yourdomain.com without specifying a language? To avoid duplicate content (SEO's greatest enemy), we must instantly redirect them to the default language version (e.g., /uk).
src/app/(root)/layout.tsx Here we create a basic shell so that Next.js can correctly process the page before the redirect occurs.
TypeScript
export default function RootRedirectLayout({ children }: { children: React.ReactNode }) {
// 🟢 FIX: Added lang="uk" attribute (the compiler will no longer complain)
return (
<html lang="uk">
<body>{children}</body>
</html>
)
}
src/app/(root)/page.tsx And here is where the server-side routing magic happens:
TypeScript
import { redirect } from 'next/navigation'
import { defaultLocale } from '@/i18n/routing'
export default function RootPage() {
// If the user visits "/", redirect to "/uk"
redirect(`/${defaultLocale}`)
}
2. The Admin Zone: src/app/(admin)
For the admin panel, we don't need SEO optimization or URL prefixes (we don't want URLs like /uk/admin/dashboard). However, we still want the ability to use the t() function for interface translations. Therefore, we "hard-link" the admin panel to the default locale.
src/app/(admin)/layout.tsx
TypeScript
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import type React from 'react'
import { Toaster } from 'react-hot-toast'
import { routing } from '@/i18n/routing'
// 1. ALL ADMIN AND LOGIN STYLES
import '@/assets-admin/css/animate.min.css'
import '@/assets-admin/css/animation.css'
import '@/assets-admin/css/bootstrap.css'
import '@/assets-admin/css/bootstrap-select.min.css'
import '@/assets-admin/css/styles.css'
import '@/assets-admin/font/fonts.css'
import '@/assets-admin/icon/style.css'
export default async function AdminGroupLayout({ children }: { children: React.ReactNode }) {
// Since the admin panel isn't multilingual, we force the default locale
const locale = routing.defaultLocale
// Get messages for this locale so t() works in the admin panel
const messages = await getMessages({ locale })
return (
<html lang={locale}>
<body>
{/* ✅ Must wrap in a provider, otherwise client components will crash */}
<NextIntlClientProvider locale={locale} messages={messages}>
{/* 🟢 NOTIFICATION COMPONENT ADDED */}
<Toaster
position="top-right"
reverseOrder={false}
toastOptions={{
duration: 4000,
style: {
background: '#333',
color: '#fff',
zIndex: 99999,
},
}}
/>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
What’s the main takeaway? We forcefully fetch the dictionary using getMessages({ locale }) and pass it into <NextIntlClientProvider>. Without this, the admin panel's client components would crash with an error because they wouldn't find the translation context.
3. The Heart of the Application: src/app/[locale]/layout.tsx
This is the most important file. It is responsible for ensuring Google correctly indexes your Ukrainian and English pages, while the user receives an instant response thanks to SSG (Static Site Generation).
Let's break down the key configurations:
TypeScript
import type { Metadata } from 'next'
import { Rajdhani, Rubik } from 'next/font/google'
import Script from 'next/script'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, setRequestLocale } from 'next-intl/server'
import type React from 'react'
import { Toaster } from 'react-hot-toast'
import LayoutWrapper from '@/components/common/LayoutWrapper'
import type { Locale } from '@/i18n/routing'
import { routing } from '@/i18n/routing'
import { ThemeProvider } from '@/providers/ThemeProvider'
export const revalidate = 60
import '@/assets-main/css/tailwind.css'
import '@/assets-main/scss/main.scss'
import '@/assets-main/css/odometer-theme-default.css'
// Font configuration
const rajdhani = Rajdhani({ subsets: ['latin'], weight: ['300', '400', '500', '600', '700'] })
const rubik = Rubik({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700', '800', '900'],
})
interface LocaleLayoutProps {
children: React.ReactNode
params: Promise<{ locale: string }>
}
// 🟢 Generate parameters for SSG
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
// 1. Base URL and GTM ID
const baseUrl = 'https://muntai.top'
const GTM_ID = 'GTM-your-code'
const seoData = {
uk: {
suffix: 'Lead Fullstack Developer | Next.js, TypeScript, Prisma',
description: 'Creating fast, scalable, and secure web platforms...',
siteName: 'Muntai - Modern Web Development | Next.js Expert',
},
en: {
suffix: 'Lead Fullstack Developer | Next.js, TypeScript, Prisma',
description: 'Building fast, scalable, and secure web applications...',
siteName: 'Muntai - Modern Web Development | Next.js Expert',
},
}
export async function generateMetadata({ params }: LocaleLayoutProps): Promise<Metadata> {
const { locale } = await params
// Strict key typing
const currentSeo = seoData[locale as keyof typeof seoData] || seoData.en
return {
metadataBase: new URL(baseUrl),
title: `Muntai - ${currentSeo.suffix}`,
description: currentSeo.description,
alternates: {
canonical: `${baseUrl}/${locale}`,
languages: {
uk: `${baseUrl}/uk`,
en: `${baseUrl}/en`,
},
},
openGraph: {
title: `Muntai - ${currentSeo.suffix}`,
description: currentSeo.description,
url: `${baseUrl}/${locale}`,
siteName: currentSeo.siteName,
type: 'website',
locale: locale,
images: [
{
url: '/assets-main/images/logo/muntai-image.jpg',
width: 1200,
height: 896,
alt: 'Muntai - Next.js & TypeScript Expert',
},
],
},
}
}
export default async function LocaleLayout(props: LocaleLayoutProps) {
const params = await props.params
const locale = params.locale as Locale
// Notify server components about the current locale
setRequestLocale(locale)
// Get translations
const messages = await getMessages({ locale })
const recaptchaKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY || ''
return (
<html lang={locale} suppressHydrationWarning>
<body className={`${rajdhani.className} ${rubik.className}`} suppressHydrationWarning>
<NextIntlClientProvider locale={locale} messages={messages}>
{/* 2. Google Tag Manager (noscript) */}
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`}
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
title="Google Tag Manager"
/>
</noscript>
{/* 1. Google Tag Manager (Script) */}
<Script id="gtm-script" strategy="afterInteractive">
{`
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${GTM_ID}');
`}
</Script>
<Script src="/assets-main/js/smooth.js" strategy="lazyOnload" />
{/* Pyodide Engine for "The Lab" */}
<Script
id="pyodide-engine"
src="https://cdn.jsdelivr.net/pyodide/v0.26.1/full/pyodide.js"
strategy="lazyOnload"
/>
{/* reCAPTCHA v3 */}
{recaptchaKey && (
<Script
id="recaptcha-script"
src={`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}`}
strategy="afterInteractive"
/>
)}
<ThemeProvider>
<LayoutWrapper>
<Toaster position="top-right" />
{props.children}
</LayoutWrapper>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
)
}
Why is this configuration ideal? The three pillars of architecture
1. SEO and Hreflang (The generateMetadata block) We dynamically form the Title and Description depending on what comes in via the locale parameter. But the most important part here is the alternates object. We are explicitly telling Google: "Look, this page is available in two languages." Thanks to languages: { uk: ..., en: ... }, the search engine understands the relationship between pages and shows the correct version for each specific region in the search results.
2. SSG: Speed matters (generateStaticParams) The generateStaticParams() function returns an array of possible locales (e.g., ['uk', 'en']). This is a signal for Next.js during the build stage (in Docker) to pre-generate static HTML versions of the pages for each language. The result? Instant loading without waiting for server-side rendering.
3. Server-to-Client Bridge (setRequestLocale and NextIntlClientProvider) Since we are using Server Components (App Router), next-intl needs to know the current locale at the server level.
setRequestLocale(locale)— fixes the language for server components.getMessages({ locale })— pulls the dictionary from the JSON file.<NextIntlClientProvider messages="{messages}">— acts as a bridge, passing dictionaries to client components ("use client"), allowing them to switch text instantly without additional requests to the backend.
This structure guarantees that your application will be strictly typed, indexed rapidly by Google, and easily scalable when you decide to add five more languages.
Step 5: The Brain of Multilingualism — the src/i18n/ directory
Our layout.tsx files are ready to accept a locale, but how does Next.js know which languages exist and how to work with them? To solve this, we create an isolated src/i18n/ directory, which serves as the "single source of truth" for the entire application.
We will place three files here. Let's break each one down.
1. Basic Rules: src/i18n/routing.ts
This file is the foundation. It tells the system which languages we support and how they should be displayed in the URL.
TypeScript
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['uk', 'en'],
defaultLocale: 'uk',
localePrefix: 'always', // URL will always have a prefix (e.g., /uk/about)
})
export const defaultLocale = routing.defaultLocale
// Export the Locale type for use in layout.tsx
export type Locale = (typeof routing.locales)[number]
Why this is professional: Pay attention to the last line. We don't just hardcode strings throughout the project; we create a strict Locale type. Now, if someone on the team tries to pass 'fr' instead of 'uk' or 'en' to a component, TypeScript will throw an error before compilation even starts. This saves you from hundreds of non-obvious bugs.
2. Smart Navigation: src/i18n/navigation.ts
In a multilingual application, you cannot use the standard Link and useRouter from next/navigation. Why? Because a standard <Link href="/about"> will redirect the user to the root, losing their current language context.
We need "smart" wrappers that are context-aware:
TypeScript
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
// Lightweight wrappers for Next.js navigation APIs that account for routing configuration
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
What this gives you: Now you import <Link> directly from this file. If an English-speaking user is on the /en/products page and clicks on <Link href="/about">, this wrapper automatically adds the prefix, and the client is navigated to /en/about. Navigation becomes seamless.
3. Server Handler and Crash Armor: src/i18n/request.ts
This file is called "under the hood" on every server request. Its task is to take the locale, pull the correct JSON dictionary, and provide it to the components. But there are some brilliant engineering decisions here:
TypeScript
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale
// Validation only
if (!locale || !(routing.locales as readonly string[]).includes(locale)) {
locale = routing.defaultLocale
}
// Loading messages without internal try/catch with locale fallback
const messages = (await import(`../messages/${locale}.json`)).default
return {
locale,
messages,
timeZone: 'Europe/Kyiv',
// 🛡 SERVER CRASH ARMOR
onError(error) {
if (error.code === 'INVALID_MESSAGE') {
console.error(
`[next-intl] Translation syntax error in locale "${locale}":`,
error.message,
)
} else {
console.error(error)
}
},
getMessageFallback({ key, namespace }) {
return namespace ? `${namespace}.${key}` : key
},
}
})
Cool features of this file:
Dynamic JSON Import:
await import(...)ensures we don't load every language in the world into server memory at once. Only the dictionary needed by the user at that millisecond is loaded.Time Localization:
timeZone: 'Europe/Kyiv'sets a single standard for date formatting. This is critical if your server is physically located in Germany, for example, but your business operates on Ukrainian time.Crash Armor (
onErrorandgetMessageFallback): What happens if a developer adds a new component but forgets to add the translation key touk.json? Normally, the server would crash with a 500 error. Our code intercepts this! Instead of a crash, the user simply sees the raw key (e.g.,Header.about_us), and a clean log appears in the server console. The application stays up.
Step 6: The Main Dispatcher — proxy.ts (Next-Auth v5 + next-intl)
In newer versions of Next.js 16 (especially when using Auth v5), the architecture has changed. Instead of the classic middleware.ts, we use proxy.ts, which acts as the main "gateway" (gatekeeper) for all incoming requests.
This is precisely where we need to bridge two complex systems: authentication (Next-Auth) and internationalization (next-intl). The challenge is compounded by the fact that the public part of the site must have URL prefixes (/uk/about), while the admin panel must not (/admin), yet the admin panel still needs access to translations.
Here is an elegant solution to this problem:
TypeScript
import { type NextRequest, NextResponse } from 'next/server'
import type { Session } from 'next-auth'
import createIntlMiddleware from 'next-intl/middleware'
import { auth } from '@/auth'
import { routing } from './i18n/routing'
const { locales, defaultLocale } = routing
// Initialize the standard internationalization middleware
const handleI18n = createIntlMiddleware({
locales,
defaultLocale,
localeDetection: false, // We force the defaultLocale if no prefix is present
})
// Wrap the entire proxy in the auth function from Auth v5
export default auth((req: NextRequest & { auth: Session | null }) => {
const { nextUrl } = req
const isLoggedIn = !!req.auth
const pathname = nextUrl.pathname
// Define zones (routes)
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/')
const isLogin = pathname === '/login' || pathname.startsWith('/login/')
// --- 1. LOGIN Handling ---
if (isLogin) {
if (isLoggedIn) {
// If already logged in, redirect to admin
return NextResponse.redirect(new URL('/admin', nextUrl))
}
// For login, force the default locale
const response = NextResponse.next()
response.headers.set('x-next-intl-locale', defaultLocale)
return response
}
// --- 2. ADMIN Handling ---
if (isAdmin) {
if (!isLoggedIn) {
// If not logged in, redirect to login and save the callback URL
const url = new URL('/login', nextUrl)
url.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(url)
}
/**
* IMPORTANT NOTE: The admin panel works without a language prefix in the URL.
* But for the t() function to work in the admin panel without errors,
* the server must know the current locale. We pass it via a header.
*/
const response = NextResponse.next()
response.headers.set('x-next-intl-locale', defaultLocale)
return response
}
// --- 3. For everything else (Public site) ---
// Here, handleI18n automatically handles prefixes (/uk, /en) and redirects
return handleI18n(req)
})
export const config = {
// Exclude static assets and APIs to prevent unnecessary proxy execution
matcher: ['/((?!api|_next|_vercel|assets-admin|assets-main|.*\\..*).*)'],
}
Breaking down the proxy.ts magic: Why does this work perfectly?
1. The auth wrapper (Auth v5) In Auth v5, we import auth from our auth.ts config file and wrap our entire proxy handler with it. This gives us instant access to the req.auth object. We know if the user is logged in before even a single pixel of the page is rendered.
2. Resolving the "Prefix-less" zone conflict The biggest pain point when setting up next-intl is pages excluded from internationalization (like our /admin or /login). next-intl demands to know the locale; otherwise, it throws an error. We solved this elegantly: we let the request pass (NextResponse.next()), but we subtly "inject" the string x-next-intl-locale: uk into the HTTP headers. Now, the admin panel loads calmly without /uk/ in the URL, and the dictionaries work like clockwork.
3. Server Resource Efficiency (Matcher) Beginners often forget about the matcher array. If not configured correctly, your proxy.ts will run for every image, CSS file, and favicon. Our config uses a regex that cuts off all requests to assets-main, assets-admin, _next, and api folders. The proxy only works when a user is genuinely navigating between pages. This saves a massive amount of your VPS resources.
Summary
Setting up internationalization in Next.js 16 is not just about installing a package. It's about building a robust architecture where each element plays its role:
Route Groups (
(root),(locale),(admin)) separate logic and isolate providers.next.config.ts(Standalone) andproxy.tsensure lightning-fast performance on the server.Strict TypeScript typing in configuration files makes it impossible to have key-related errors.
Using this approach, you get a flexible, SEO-optimized system ready to scale to 20 languages while maintaining top-tier performance and a perfect Google PageSpeed score.
Step 7: Translation Dictionaries (src/messages/)
Our entire architecture is built around JSON files that store text. We create a src/messages/ folder and add our dictionaries there. The golden rule here is structure. Don't dump all translations into one flat level; use nested objects (namespaces) to organize text by pages or components.
src/messages/uk.json
JSON
{
"HomePage": {
"title": "Сучасна веб-розробка",
"subtitle": "Створюємо швидкі та безпечні платформи",
"cta_button": "Обговорити проєкт"
},
"Navigation": {
"about": "Про нас",
"services": "Послуги",
"contact": "Контакти"
}
}
src/messages/en.json
JSON
{
"HomePage": {
"title": "Modern Web Development",
"subtitle": "Building fast and secure platforms",
"cta_button": "Discuss a project"
},
"Navigation": {
"about": "About",
"services": "Services",
"contact": "Contact"
}
}
Pro-tip: Since we enabled strict typing, if you try to call the key HomePage.title in your code and it’s accidentally deleted from en.json, TypeScript will immediately underline it in red. You will never deploy a page with "gaps" in the text.
Step 8: How it looks in practice (Example Page)
Now, let’s see how easy it is to use these translations in our strictly typed Next.js 16 code. We are using Server Components, meaning translation happens directly on the server, instantly delivering the finished HTML to the browser.
Here is our homepage: src/app/[locale]/page.tsx
TypeScript
import { getTranslations } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
export default async function HomePage() {
// Call the dictionary specifically for the "HomePage" namespace
// Since this is a server component, we use getTranslations from 'next-intl/server'
const t = await getTranslations('HomePage')
const nav = await getTranslations('Navigation')
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
{/* Navigation with our smart Link wrapper */}
<nav className="mb-12 flex gap-6">
<Link href="/about" className="text-lg hover:text-blue-500">
{nav('about')}
</Link>
<Link href="/services" className="text-lg hover:text-blue-500">
{nav('services')}
</Link>
</nav>
{/* Main page content */}
<div className="text-center">
<h1 className="mb-4 text-6xl font-bold tracking-tight">
{t('title')}
</h1>
<p className="mb-8 text-xl text-gray-600">
{t('subtitle')}
</p>
<button className="rounded-lg bg-blue-600 px-8 py-3 text-white transition hover:bg-blue-700">
{t('cta_button')}
</button>
</div>
</main>
)
}
Pay attention to the key differences:
Server Rendering: We use
async/awaitandgetTranslations('HomePage'). This means no extra translation JavaScript libraries are sent to the client. The browser receives clean HTML with the text already filled in (e.g., "Modern Web Development").Smart
Link: Notice the importimport { Link } from '@/i18n/navigation'. When the user clicks the/aboutlink, this wrapper automatically understands the current language context and redirects them to/uk/aboutor/en/about.
Summary
Creating multilingual support isn't just about swapping text. It's about building reliable infrastructure. By using next-intl in conjunction with App Router, proxy.ts, and TypeScript, you get an Enterprise-level system.
Your site flies thanks to SSG, Google perfectly indexes all language versions, the admin panel lives its own isolated life, and you, as a developer, enjoy the lack of bugs thanks to strict dictionary typing.

Коментарі (0)
Leave a comment