Localized URLs help users find content in their language and region. Proper URL structure for internationalization (i18n) improves SEO and user experience across global audiences.
Key Takeaways
- 1Use path prefixes (/en/, /fr/) for language targeting
- 2Subdomains or ccTLDs for country targeting
- 3Implement hreflang tags for language alternatives
- 4Keep URL slugs consistent or translate them
- 5Don't use IP detection alone for redirects
“Use hreflang to tell Google about localized versions of your page. This helps Google serve the correct language or regional URL to users in search results.”
URL Localization Strategies
There are several ways to indicate language and region in URLs. Each approach has implications for SEO, implementation complexity, and infrastructure requirements.
| Strategy | Example | Best For |
|---|---|---|
| Path prefix | /en/products | Language targeting |
| Subdomain | en.example.com | Regional sites |
| ccTLD | example.de | Country targeting |
| Query param | ?lang=en | Not recommended |
Path prefixes are the most common choice for language targeting. Country-code TLDs (.de, .fr) are best for country-specific content and SEO. Query parameters are not recommended because search engines may ignore them.
Path Prefix Localization
Path prefixes like /en/ and /fr/ are the most flexible and widely supported approach. They work with any hosting setup and are clearly visible to users and search engines.
// Path-based locale: /en/products, /fr/products
// Next.js i18n configuration
// next.config.js
module.exports = {
i18n: {
locales: ['en', 'fr', 'de', 'es'],
defaultLocale: 'en',
localeDetection: true
}
};
// Middleware for custom locale handling
import { NextResponse } from 'next/server';
const LOCALES = ['en', 'fr', 'de', 'es'];
const DEFAULT_LOCALE = 'en';
export function middleware(request) {
const pathname = request.nextUrl.pathname;
// Check if locale is in path
const pathnameLocale = LOCALES.find(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameLocale) {
// Locale found in path, continue
return NextResponse.next();
}
// Detect preferred locale
const acceptLanguage = request.headers.get('accept-language') || '';
const preferredLocale = acceptLanguage.split(',')[0].split('-')[0];
const locale = LOCALES.includes(preferredLocale) ? preferredLocale : DEFAULT_LOCALE;
// Redirect to localized path
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}The middleware detects the locale from the URL path first, then falls back to browser language preferences. Users without a locale prefix are redirected to their preferred language version.
Search engines need explicit signals to understand the relationship between language versions of your pages. Hreflang annotations tell Google which URLs serve which audiences.
Hreflang Implementation
Hreflang tags in your HTML head tell search engines about all language versions of a page. This prevents duplicate content issues and helps users find content in their preferred language.
<!-- Hreflang tags for language alternatives -->
<head>
<link rel="alternate" hreflang="en" href="https://example.com/en/products" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/products" />
<link rel="alternate" hreflang="de" href="https://example.com/de/products" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/products" />
</head>
<!-- With region targeting -->
<head>
<link rel="alternate" hreflang="en-us" href="https://example.com/en-us/products" />
<link rel="alternate" hreflang="en-gb" href="https://example.com/en-gb/products" />
<link rel="alternate" hreflang="en" href="https://example.com/en/products" />
<link rel="alternate" hreflang="x-default" href="https://example.com/products" />
</head>
<!-- Generate in React/Next.js -->
{locales.map(locale => (
<link
key={locale}
rel="alternate"
hreflang={locale}
href={`https://example.com/${locale}${pathname}`}
/>
))}
<link rel="alternate" hreflang="x-default" href={`https://example.com${pathname}`} />The x-default hreflang indicates the fallback version for users whose language is not explicitly listed. Every localized page must include links to all its language variants.
Beyond the language prefix, you may want to translate the URL slugs themselves. This decision has significant implications for maintenance and SEO.
Translated URL Slugs
You can either keep URL slugs in the original language across all versions or translate them for each locale. Translated slugs are better for local SEO but harder to maintain.
// Option 1: Keep slugs in original language (recommended)
// Easier to maintain, works for non-latin scripts
// /en/products/running-shoes
// /fr/products/running-shoes
// /ja/products/running-shoes
// Option 2: Translate slugs
// Better for SEO in target language
// /en/products/running-shoes
// /fr/produits/chaussures-de-course
// /de/produkte/laufschuhe
// Slug translation mapping
const slugTranslations = {
'running-shoes': {
en: 'running-shoes',
fr: 'chaussures-de-course',
de: 'laufschuhe',
es: 'zapatillas-running'
}
};
// Resolve translated slug to canonical
function resolveSlug(locale, translatedSlug) {
for (const [canonical, translations] of Object.entries(slugTranslations)) {
if (translations[locale] === translatedSlug) {
return canonical;
}
}
return translatedSlug; // Fall back to input
}
// Generate localized URL
function getLocalizedUrl(locale, canonicalSlug) {
const translations = slugTranslations[canonicalSlug];
const localizedSlug = translations?.[locale] || canonicalSlug;
return `/${locale}/products/${localizedSlug}`;
}The slug translation mapping stores equivalents for each locale. The resolveSlug function converts localized slugs back to canonical form for database lookups.
When users first arrive at your site, you need to determine which language version to show them. There are several signals to consider, in order of reliability.
Locale Detection
Locale detection should prioritize explicit user choices over automatic detection. Use the URL first, then saved preferences, then browser settings, and only then geolocation.
// Locale detection priority
// 1. URL path/subdomain (explicit choice)
// 2. Cookie preference (previous choice)
// 3. Accept-Language header
// 4. IP geolocation (with caution)
// 5. Default locale
function detectLocale(req) {
// 1. Check URL
const urlLocale = getLocaleFromPath(req.path);
if (urlLocale) return urlLocale;
// 2. Check cookie
const cookieLocale = req.cookies.get('locale');
if (cookieLocale && VALID_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
// 3. Check Accept-Language
const acceptLang = req.headers.get('accept-language');
if (acceptLang) {
const browserLocale = parseAcceptLanguage(acceptLang);
if (VALID_LOCALES.includes(browserLocale)) {
return browserLocale;
}
}
// 4. Geolocation (optional, for country-specific)
// Be careful: VPNs, travelers, preferences differ from location
const country = req.geo?.country;
const geoLocale = COUNTRY_LOCALE_MAP[country];
if (geoLocale) {
return geoLocale;
}
// 5. Default
return DEFAULT_LOCALE;
}
// Parse Accept-Language header
function parseAcceptLanguage(header) {
const langs = header.split(',').map(lang => {
const [code, q = 'q=1'] = lang.trim().split(';');
return { code: code.split('-')[0], q: parseFloat(q.split('=')[1]) };
});
langs.sort((a, b) => b.q - a.q);
return langs[0]?.code;
}This detection function checks sources in priority order, falling back through the chain until it finds a valid locale. Geolocation is used cautiously since users may be traveling or using VPNs.
Users need an easy way to switch languages if automatic detection gets it wrong. A well-designed language switcher preserves the current page context.
Language Switcher
The language switcher should take users to the equivalent page in their chosen language, not just the homepage. It should also save their preference for future visits.
// Language switcher component
function LanguageSwitcher({ currentLocale, currentPath }) {
const locales = ['en', 'fr', 'de', 'es'];
const getLocalizedPath = (locale) => {
// Replace current locale in path
return currentPath.replace(`/${currentLocale}/`, `/${locale}/`);
};
return (
<nav>
{locales.map(locale => (
<a
key={locale}
href={getLocalizedPath(locale)}
hrefLang={locale}
className={locale === currentLocale ? 'active' : ''}
onClick={() => {
// Save preference in cookie
document.cookie = `locale=${locale}; path=/; max-age=31536000`;
}}
>
{getLanguageName(locale)}
</a>
))}
</nav>
);
}
// Handle untranslated pages
function getLocalizedPath(locale, pathname) {
// Check if translation exists
const translatedPath = translations[locale]?.[pathname];
if (translatedPath) {
return `/${locale}${translatedPath}`;
}
// Fall back to current path with new locale
// Or redirect to localized home
return `/${locale}${pathname}`;
}The switcher replaces the current locale in the URL path while preserving the rest of the path. A cookie saves the preference so it persists across sessions.
Sometimes you need to target both language and region, such as French for Canada versus French for France. These combinations require more nuanced URL structures.
Regional URL Variations
Regional variations combine language and country targeting. Use this when content, pricing, or regulations differ by region within the same language.
# Language + Region combinations
# Path format: /{language}-{region}/
/en-us/products # English, United States
/en-gb/products # English, United Kingdom
/fr-ca/products # French, Canada
/fr-fr/products # French, France
# Subdomain format:
us.example.com # United States
uk.example.com # United Kingdom
ca.example.com # Canada (with language toggle)
# Mixed (common for ecommerce):
example.com/en-us/ # Language in path
store-us.example.com/en/ # Region in subdomain, language in path
# Currency handling (don't put in URL)
# Use session/cookie for currency preference
# Or derive from region in URLThe language-region format (en-us, en-gb) clearly indicates both dimensions. Keep currency in session storage rather than URLs to avoid creating unnecessary URL variations.