Tech Verse Logo
Enable dark mode
Next.js i18n Routing: Locales, Hreflang, and Metadata

Next.js i18n Routing: Locales, Hreflang, and Metadata

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Next.js 16 doesn't give you a built-in i18n router like the old Pages Router did. You have to build locale routing yourself using directory structures, middleware, and metadata APIs. Here is how to construct a reliable setup for locale segments, hreflang headers, and metadata generation that works cleanly in production.

Setting Up Dynamic Locale Segments in App Router

In Next.js 16 and React 19, internationalization relies on the app/[lang] directory structure. Every route sits under the [lang] dynamic segment. If a user hits /en/products or /fr/products, Next.js passes lang into route params for layouts and pages.

Where developers often run into problems is root path handling. When someone requests /, you want your middleware to detect their preferred browser language using headers like Accept-Language and redirect them to /en or /fr.

Here is a middleware implementation using Negotiator and @formatjs/intl-localematcher:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';

const locales = ['en', 'fr', 'es', 'de'];
const defaultLocale = 'en';

function getLocale(request: NextRequest): string {
  const headers = { 'accept-language': request.headers.get('accept-language') || '' };
  const languages = new Negotiator({ headers }).languages();
  return match(languages, locales, defaultLocale);
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Skip internal asset paths and API routes
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) return NextResponse.next();

  const locale = getLocale(request);
  request.nextUrl.pathname = `/${locale}${pathname}`;

  return NextResponse.redirect(request.nextUrl);
}

export const config = {
  matcher: ['/((?!_next|api|favicon.ico).*)'],
};

Checking pathnameHasLocale before running negotiator functions is critical. Running header negotiation on every static asset request wastes server CPU cycles and adds about 8ms of unnecessary latency per request.

Generating Translated Metadata and Hreflang Tags

Google cares deeply about hreflang attributes when indexing multi-language sites. If you don't explicitly declare alternate language URLs in your HTML head, search engine bots will treat translated pages as duplicate content. That destroys your search ranking across localized domains.

Next.js 16 provides the generateMetadata function inside layout or page files. Instead of manually writing <link rel="alternate"> elements, you return an alternates configuration object from generateMetadata. Next.js automatically outputs HTML tags with matching canonical and language references.

Here is how you set this up in app/[lang]/layout.tsx:

import type { Metadata } from 'next';

const locales = ['en', 'fr', 'es', 'de'];
const domain = 'https://example.com';

type Props = {
  params: Promise<{ lang: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang } = await params;
  
  // Build language alternates mapping
  const languageAlternates: Record<string, string> = {};
  for (const l of locales) {
    languageAlternates[l] = `${domain}/${l}`;
  }
  
  // Set default fallback tag x-default
  languageAlternates['x-default'] = `${domain}/en`;

  return {
    title: {
      default: lang === 'fr' ? 'Accueil - Tech Verse' : 'Home - Tech Verse',
      template: '%s | Tech Verse',
    },
    alternates: {
      canonical: `${domain}/${lang}`,
      languages: languageAlternates,
    },
  };
}

export default async function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  );
}

In Next.js 16 and React 19, params in layouts and pages is a Promise, so you must await params before reading properties. Skipping this step causes runtime errors during static site generation builds.

Pay attention to the x-default entry in languageAlternates. Skipping x-default causes Google Search Console to raise warnings because it can't determine where to send users whose language isn't explicitly targeted.

Syncing API Requests with Laravel 12 Backend

If your Next.js frontend connects to a PHP 8.3 API built with Laravel 12, your backend needs to know which locale to render or fetch from database models. Passing the lang parameter in every single backend endpoint URL gets messy. Instead, send the route locale inside an Accept-Language header in your server fetch client.

Here is a server-side fetch wrapper in Next.js that passes the target locale to Laravel:

import { cache } from 'react';

export const fetchFromLaravel = cache(async (endpoint: string, lang: string) => {
  const baseUrl = process.env.LARAVEL_API_URL || 'https://api.example.com';
  
  const res = await fetch(`${baseUrl}/api/v1/${endpoint}`, {
    headers: {
      'Accept-Language': lang,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    next: { revalidate: 3600 },
  });

  if (!res.ok) {
    throw new Error(`Laravel API return error: ${res.statusText}`);
  }

  return res.json();
});

On the Laravel 12 side, add HTTP middleware to assign the application locale globally based on that incoming header:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;

class SetApiLocale
{
    public function handle(Request $request, Closure $next): Response
    {
        $locale = $request->header('Accept-Language', config('app.locale'));
        
        $supportedLocales = ['en', 'fr', 'es', 'de'];
        
        if (in_array($locale, $supportedLocales, true)) {
            App::setLocale($locale);
        } else {
            App::setLocale(config('app.fallback_locale', 'en'));
        }

        return $next($request);
    }
}

This keeps Laravel translation strings, validation messages, and packages like spatie/laravel-translatable synchronized with Next.js without polluting API endpoints with duplicate query parameters.

Gotchas: Caching, Hardcoded Links, and Static Exports

Dynamic Rendering Triggers in Middleware

Reading headers or cookies inside middleware without strict route matchers turns off automatic static optimization for downstream routes. If you want fast response times on static pages, return NextResponse.next() immediately for already-localized URLs.

Hardcoded Link Paths

Using raw standard link components like <Link href="/about"> strips away active locale context. When a user browsing /fr/ clicks <Link href="/about">, Next.js sends them back to /about, triggering another round of middleware redirects. Build a wrapper component that appends the current locale param to internal paths.

Static Site Generation Export Compatibility

If you run Next.js with output: 'export', middleware logic is completely ignored during output generation. In SSG mode, define generateStaticParams() inside combination layouts to generate localized path pages during build time:

export async function generateStaticParams() {
  return [
    { lang: 'en' },
    { lang: 'fr' },
    { lang: 'es' },
    { lang: 'de' }
  ];
}

Without generateStaticParams(), Next.js renders static pages only for default fallbacks, yielding 404 responses for secondary locale paths in production exports.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Fixing Next.js Hydration Mismatch Errors in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Generating Open Graph Images at the Edge in Next.js 16

    Generating Open Graph Images at the Edge in Next.js 16

    Fix Next.js Third Party Script Performance

    Fix Next.js Third Party Script Performance

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Next.js 16 App Router Auth Patterns with Laravel 12

    Next.js 16 App Router Auth Patterns with Laravel 12

    Next.js i18n Routing: Locales, Hreflang, and Metadata

    Next.js i18n Routing: Locales, Hreflang, and Metadata

    Next.js 16 Caching Architecture: The Four Layers

    Next.js 16 Caching Architecture: The Four Layers

    Dynamic Imports and Code Splitting in Next.js 16

    Dynamic Imports and Code Splitting in Next.js 16

    Next.js Image Optimization: Sizes and Remote Patterns

    Next.js Image Optimization: Sizes and Remote Patterns