Skip to content

i18n Routing — URL-Based Locale Routing Strategies

DodaTech Updated 2026-06-28 7 min read

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. What are the three main URL-based locale routing strategies?
  2. Why is path-prefix routing (/en/page) preferred over query parameters (?lang=en)?
  3. How does subdomain routing (en.example.com) affect SEO differently from path routing?
  4. How do you handle first-time Visitor locale detection with redirect?
  5. 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

Which URL routing strategy is best for SEO?

Path prefix (/en/page) is the best balance. Subdomains (en.example.com) are also good but may dilute domain authority. Query parameters (?lang=en) are worst for SEO. Country-specific domains (.co.uk, .fr) are strongest but most expensive.

How do I handle the root URL?

Redirect / to the detected locale (/en/). Use Accept-Language header for first visit, cookie for subsequent visits. Give users a way to override and persist their choice.

Should I include the default locale in the URL?

Yes. Even for the default language, use /en/page rather than /page. This keeps URL structure consistent across all locales and prevents duplicate content issues.

{{< 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." >}}

How do I handle locale-specific redirects after login?

Store the user's preferred locale in their profile. After login, redirect to /{locale}/dashboard. If the URL already has a locale prefix, respect it even if it differs from the user's preference.

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