i18n Routing — URL-Based Locale Routing Strategies
In this tutorial, you will learn about i18n routing. We cover key concepts, practical examples, and best practices to help you master this topic.
i18n routing uses URL-based locale strategies including subdomain, path prefix, and domain-based routing for multilingual web applications.
What You'll Learn
By the end of this tutorial, you'll understand the three main URL-based locale routing strategies, how to implement locale routing in Express and Next.js, how to handle redirects and fallbacks, and the SEO implications of each approach.
Why It Matters
URL-based locale routing determines how search engines index your multilingual content, how users share links across languages, and how the application determines the locale on the server. The wrong Strategy can cause duplicate content issues, lost SEO rankings, and confusing user experiences when sharing links.
Real-World Use
A content platform uses path-prefix routing: /en/blog/post, /es/blog/post, /ar/blog/post. The server detects the locale from the URL path, renders the correct language, and sets hreflang tags. A user in Spain shares a link with a friend in the US — the link preserves the Spanish locale. Search engines index each language version as a separate URL without duplicate content penalties.
Routing Strategies
graph LR
A[i18n Routing
Strategies] --> B[Subdomain
en.example.com]
A --> C[Path Prefix
example.com/en/]
A --> D[Domain
example.co.uk]
A --> E[Query Parameter
example.com?lang=en]
B --> F[Dedicated subdomain
per locale]
B --> G[SEO: Strong signals
Separate site]
C --> H[Path prefix for
each locale]
C --> I[SEO: Secondary signals
Same domain]
D --> J[Country-specific
domains]
D --> K[SEO: Strongest signals
Max localization]
E --> L[URL parameter
for locale]
E --> M[SEO: Poorest signals
Shared URL]
style A fill:#4a90d9,color:#fff
style C fill:#27ae60,color:#fff
style B fill:#f39c12,color:#fff
Path Prefix Routing in Express
// server/i18n-router.js — Path prefix routing with Express
const express = require('express');
const path = require('path');
class I18nRouter {
constructor(options = {}) {
this.options = {
supportedLocales: options.supportedLocales || ['en', 'es', 'fr', 'ar'],
defaultLocale: options.defaultLocale || 'en',
translationsDir: options.translationsDir || path.join(__dirname, '..', 'locales'),
...options
};
}
// Middleware to detect locale from URL path
localeMiddleware() {
return (req, res, next) => {
// Extract locale from path: /en/products → en
const match = req.path.match(/^\/(\w{2}(?:-\w{2})?)(\/|$)/);
const detectedLocale = match ? match[1] : null;
if (this.options.supportedLocales.includes(detectedLocale)) {
req.locale = detectedLocale;
// Strip locale from path for route matching
req.url = req.path.replace(/^\/\w{2}(?:-\w{2})?/, '') || '/';
} else {
// Not a valid locale in path — redirect to default
if (detectedLocale) {
// Unknown locale prefix — redirect to default
return this.redirectToLocale(req, res, this.options.defaultLocale);
}
req.locale = this.options.defaultLocale;
}
// Store original URL for hreflang links
req.originalPath = req.originalUrl;
res.locals.locale = req.locale;
res.locals.alternates = this.getAlternates(req);
next();
};
}
// Generate hreflang alternate URLs
getAlternates(req) {
const baseUrl = `${req.protocol}://${req.headers.host}`;
const pathWithoutLocale = req.path.replace(/^\/(\w{2}(?:-\w{2})?)/, '');
return this.options.supportedLocales.map(locale => ({
locale,
href: `${baseUrl}/${locale}${pathWithoutLocale || '/'}`
}));
}
// Redirect to locale-prefixed URL
redirectToLocale(req, res, locale) {
const path = req.path || '/';
// If the path already starts with a locale, replace it
const newPath = path.replace(/^\/(\w{2}(?:-\w{2})?)/, `/${locale}`);
res.redirect(302, `/${locale}${newPath}`);
}
// Detect locale from Accept-Language for first visit
detectFromBrowser(req) {
const acceptLanguage = req.headers['accept-language'];
if (!acceptLanguage) return this.options.defaultLocale;
const preferences = acceptLanguage
.split(',')
.map(entry => {
const [locale, q] = entry.trim().split(';q=');
return { locale: locale.split('-')[0], quality: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.quality - a.quality);
for (const pref of preferences) {
if (this.options.supportedLocales.includes(pref.locale)) {
return pref.locale;
}
}
return this.options.defaultLocale;
}
// Setup routes
setup(app) {
// Root path — redirect to detected locale
app.get('/', (req, res) => {
const locale = req.cookies?.locale
|| this.detectFromBrowser(req)
|| this.options.defaultLocale;
res.redirect(302, `/${locale}/`);
});
// Apply locale middleware
app.use(this.localeMiddleware());
// Serve localized static files
app.use((req, res, next) => {
const locale = req.locale;
// Example: serve different translation files
req.t = (key) => {
// Translation lookup logic
return key;
};
next();
});
}
}
module.exports = I18nRouter;
Next.js i18n Routing
// next.config.js — Next.js built-in i18n routing
module.exports = {
i18n: {
// Supported locales
locales: ['en', 'es', 'fr', 'de', 'ar', 'ja'],
// Default locale (used as fallback and for root path)
defaultLocale: 'en',
// Locale detection from Accept-Language
localeDetection: true,
// Domain-based routing (optional)
domains: [
{
domain: 'example.com',
defaultLocale: 'en',
},
{
domain: 'example.es',
defaultLocale: 'es',
},
{
domain: 'example.fr',
defaultLocale: 'fr',
},
{
domain: 'example.de',
defaultLocale: 'de',
},
],
},
};
// pages/[locale]/products/[id].js — Locale-aware Next.js page
import { useRouter } from 'next/router';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
export default function ProductPage({ product }) {
const router = useRouter();
const { locale } = router;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Locale switcher links */}
<nav aria-label="Language switcher">
{router.locales.map(localeCode => (
<a
key={localeCode}
href={router.asPath}
locale={localeCode}
hrefLang={localeCode}
>
{localeCode.toUpperCase()}
</a>
))}
</nav>
</div>
);
}
export async function getServerSideProps({ locale, params }) {
return {
props: {
...(await serverSideTranslations(locale, ['common', 'product'])),
product: await getProduct(params.id, locale),
},
};
}
Redirect and Fallback Logic
// server/locale-redirect.js — Smart locale redirects
class LocaleRedirector {
constructor(options = {}) {
this.supportedLocales = options.supportedLocales || ['en', 'es', 'fr'];
this.defaultLocale = options.defaultLocale || 'en';
}
// Determine the best locale for a first-time visitor
determineLocale(req) {
// Priority: 1. Cookie, 2. Accept-Language, 3. Default
const cookie = req.cookies?.locale;
if (cookie && this.supportedLocales.includes(cookie)) {
return cookie;
}
const acceptLanguage = req.headers['accept-language'];
if (acceptLanguage) {
const match = acceptLanguage
.split(',')
.map(e => {
const [locale, q] = e.trim().split(';q=');
return { locale: locale.split('-')[0], quality: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.quality - a.quality)
.find(pref => this.supportedLocales.includes(pref.locale));
if (match) return match.locale;
}
return this.defaultLocale;
}
// Handle root path redirect
handleRoot(req, res) {
const locale = this.determineLocale(req);
const cookieLocale = req.cookies?.locale;
// If user has a saved preference, use it
if (cookieLocale && this.supportedLocales.includes(cookieLocale)) {
return res.redirect(302, `/${cookieLocale}/`);
}
// Otherwise redirect based on browser detection
return res.redirect(302, `/${locale}/`);
}
// Handle 404 for locale paths
handleNotFound(req, res) {
const locale = req.locale || this.defaultLocale;
res.status(404).render('404', { locale });
}
// Handle locale mismatch — redirect to correct locale
handleMismatch(req, res, targetLocale) {
const path = req.path.replace(/^\/(\w{2}(?:-\w{2})?)/, `/${targetLocale}`);
res.redirect(301, path);
}
}
Common Mistakes
- Not providing locale-specific sitemaps. Each locale should have its own sitemap.xml (or entries in a single sitemap with hreflang annotations). Without this, search engines may not discover all language versions.
- Using query parameter routing (?lang=en). Query parameters are ignored by many search engines for indexing. URL path (/en/) or subdomain (en.example.com) are strongly preferred for SEO.
- Inconsistent locale codes in URLs. Use the same BCP 47 codes in URLs as in your translation files and hreflang tags. Mixing "en-US" in hreflang with "en" in URLs causes confusion.
- No canonical URLs across locales. Each locale version should have a self-referencing canonical URL and hreflang alternates pointing to other locale versions. This prevents duplicate content penalties.
- Redirect loops in locale detection. If the user's browser language is Spanish but Spanish isn't supported, the redirect to default locale should not re-trigger detection. Use a session flag or cookie to prevent loops.
Practice Questions
- What are the three main URL-based locale routing strategies?
- Why is path-prefix routing (/en/page) preferred over query parameters (?lang=en)?
- How does subdomain routing (en.example.com) affect SEO differently from path routing?
- How do you handle first-time Visitor locale detection with redirect?
- Why is the Vary: Accept-Language header important for i18n routing?
Challenge: Implement a complete i18n routing system with path-prefix strategy supporting 4 locales. Include: first-visit redirect based on Accept-Language, locale persistence via cookie, proper hreflang tags in HTML head, locale-specific sitemap generation, 404 handling per locale, and no redirect loops.
FAQ
{{< faq "How do hreflang tags work with i18n routing?" "Each page should include <link rel="alternate" hreflang="es" href="/es/page"> for every supported locale. The hreflang value must match the locale in the URL and the Content-Language header." >}}
Mini Project
Build a multilingual site with path-prefix i18n routing supporting 4 locales (English, Spanish, French, Arabic). Implement: first-visit redirect from / to the detected locale, cookie-based persistence, locale switcher that navigates to the same path in a different language, hreflang tags in
, 404 page per locale, and a sitemap with hreflang annotations for all locale versions.What's Next
You've mastered i18n routing. Next, learn about i18n SEO for hreflang tags and multilingual SEO best practices.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro