JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
PreviousNext

Next.js Internationalization (i18n) Without Locale Routing - Complete Guide with next-intl

Learn how to implement multi-language support in Next.js 15 without changing URLs. Master automatic browser language detection, user preference persistence with cookies, dynamic metadata translation, and seamless integration with both client and server components using next-intl. Includes complete code examples, best practices, and troubleshooting guide for building production-ready multilingual applications.

Next.js Internationalization (i18n) Without Locale Routing

Complete Guide to Implementing Multi-Language Support Using next-intl


Table of Contents

  1. Overview
  2. Prerequisites
  3. Project Setup
  4. Installation
  5. Configuration
  6. Creating Translation Files
  7. Implementation
  8. Browser Language Detection
  9. User Preference Management
  10. Dynamic Metadata
  11. Complete Code Examples

Overview

This documentation demonstrates how to implement multi-language support in a Next.js application without changing the URL structure. Unlike traditional i18n implementations that use route prefixes (e.g., /en/page, /fr/page), this approach maintains clean URLs while still providing full internationalization capabilities.

Key Features

  • No URL changes - Routes remain the same regardless of language
  • Automatic browser language detection - Detects user's default browser language on first visit
  • User preference persistence - Remembers user's language choice using cookies
  • Dynamic page metadata - Updates page titles based on selected language
  • Works with both client and server components

Prerequisites

  • Node.js installed
  • Basic knowledge of Next.js and React
  • Next.js 15 (or compatible version)

Project Setup

Step 1: Create a New Next.js Project

pnpm create next-app@latest .

Answer the configuration prompts:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • Source directory: No
  • App Router: Yes
  • Turbo pack: No (optional)
  • Import Alias: No (or customize as needed)

Step 2: Verify Installation

pnpm dev

Visit http://localhost:3000 to ensure the project is running correctly.


Installation

Install next-intl Package

pnpm add next-intl

This package will appear in your package.json file under dependencies.


Configuration

Step 1: Configure next.config.ts

Open next.config.ts and add the following configuration:

import type { NextConfig } from "next";
 
// @ts-expect-error - next-intl requires this import style
const createNextIntlPlugin = require("next-intl/plugin");
 
const withNextIntl = createNextIntlPlugin();
 
const nextConfig: NextConfig = {
  /* config options here */
};
 
export default withNextIntl(nextConfig);

Important Notes:

  • The require syntax is recommended by next-intl documentation for .ts/.js config files
  • The TypeScript error can be safely ignored using the @ts-expect-error comment
  • For .mjs/.mts extensions, you can use ES6 import syntax

Step 2: Create i18n Request Configuration

Create a new folder and file: i18n/request.ts

import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
 
export default getRequestConfig(async () => {
  // Get stored language preference from cookies
  const cookieStore = await cookies();
  const cookieLocale = cookieStore.get("my_nextapp_locale")?.value;
 
  // Use cookie locale if it exists, otherwise default to 'en'
  const locale = cookieLocale || "en";
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Key Points:

  • cookies() must be awaited in Next.js 15
  • The cookie name (my_nextapp_locale) should be unique to avoid conflicts
  • Messages are dynamically imported based on the selected locale

Creating Translation Files

Step 1: Create Messages Directory

Create a messages folder in your project root.

Step 2: Create Language Files

Create JSON files for each supported language:

messages/en.json

{
  "tabTitles": {
    "home": "Create Next App"
  },
  "homePage": {
    "listOne": "Get started by editing",
    "listTwo": "Save and see your changes instantly."
  }
}

messages/fr.json

{
  "tabTitles": {
    "home": "Créer une application Next"
  },
  "homePage": {
    "listOne": "Commencez par éditer",
    "listTwo": "Enregistrez et voyez vos modifications instantanément."
  }
}

Important Guidelines:

  • Keys must be identical across all language files
  • Only values should differ
  • Use consistent structure and nesting
  • Add as many language files as needed (e.g., de.json, es.json, etc.)

Implementation

Step 1: Set Up the Layout

Modify app/layout.tsx:

import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const locale = await getLocale();
  const messages = await getMessages();
 
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          <div className="mx-auto flex h-screen max-w-6xl flex-col">
            <Navbar />
            <div className="flex flex-grow items-center justify-center">
              {children}
            </div>
          </div>
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Key Changes:

  • Wrap children with NextIntlClientProvider
  • Pass messages and locale from server functions
  • Layout function must be async

Step 2: Create the Navbar Component

Create components/Navbar.tsx:

"use client";
 
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
 
export default function Navbar() {
  const [locale, setLocale] = useState("");
  const router = useRouter();
 
  useEffect(() => {
    // Check if user has a stored language preference
    const cookieLocale = document.cookie
      .split("; ")
      .find((row) => row.startsWith("my_nextapp_locale="))
      ?.split("=")[1];
 
    if (cookieLocale) {
      // Use stored preference
      setLocale(cookieLocale);
    } else {
      // Detect browser's default language
      const browserLocale = navigator.language.slice(0, 2);
      setLocale(browserLocale);
 
      // Store browser language as initial preference
      document.cookie = `my_nextapp_locale=${browserLocale}; path=/`;
      router.refresh();
    }
  }, [router]);
 
  const changeLanguage = (newLocale: string) => {
    setLocale(newLocale);
    document.cookie = `my_nextapp_locale=${newLocale}; path=/`;
    router.refresh();
  };
 
  return (
    <nav className="flex items-center justify-between p-4">
      <h1 className="text-2xl font-bold">Logo</h1>
      <div className="flex gap-2">
        <button
          onClick={() => changeLanguage("en")}
          className={`rounded px-4 py-2 ${
            locale === "en" ? "bg-blue-500 text-white" : "bg-gray-200"
          }`}
        >
          EN
        </button>
        <button
          onClick={() => changeLanguage("fr")}
          className={`rounded px-4 py-2 ${
            locale === "fr" ? "bg-blue-500 text-white" : "bg-gray-200"
          }`}
        >
          FR
        </button>
      </div>
    </nav>
  );
}

Component Features:

  • Detects browser language on first load
  • Stores user preference in cookies
  • Refreshes page when language changes
  • Visual indication of selected language

Step 3: Use Translations in Pages

Modify app/page.tsx:

import { useTranslations } from "next-intl";
 
export default function Home() {
  const t = useTranslations("homePage");
 
  return (
    <main className="flex flex-col items-center gap-8">
      <div>
        <ol>
          <li>
            <code>{t("listOne")}</code> app/page.tsx
          </li>
          <li>{t("listTwo")}</li>
        </ol>
      </div>
    </main>
  );
}

Usage Notes:

  • useTranslations('homePage') accesses the homePage object from JSON files
  • Works in both client and server components
  • To use in client components, add 'use client' directive

Browser Language Detection

How It Works

The language detection follows this priority:

  1. First Visit:

    • Checks if cookie my_nextapp_locale exists
    • If not, detects browser's default language using navigator.language
    • Stores detected language in cookie
    • Sets this as active language
  2. Subsequent Visits:

    • Reads language from cookie
    • Uses stored preference (ignores browser default)

Implementation in Navbar

useEffect(() => {
  const cookieLocale = document.cookie
    .split("; ")
    .find((row) => row.startsWith("my_nextapp_locale="))
    ?.split("=")[1];
 
  if (cookieLocale) {
    setLocale(cookieLocale);
  } else {
    const browserLocale = navigator.language.slice(0, 2);
    setLocale(browserLocale);
    document.cookie = `my_nextapp_locale=${browserLocale}; path=/`;
    router.refresh();
  }
}, [router]);

User Preference Management

Storing Language Preference

When a user changes language:

const changeLanguage = (newLocale: string) => {
  setLocale(newLocale);
  document.cookie = `my_nextapp_locale=${newLocale}; path=/`;
  router.refresh();
};

Steps:

  1. Update local state
  2. Save to cookie
  3. Refresh router to reload with new language
  • Name: my_nextapp_locale (customize as needed)
  • Path: / (accessible across entire site)
  • Persistence: Cookie persists across browser sessions
  • Scope: Domain-specific (won't conflict with other sites)

Dynamic Metadata

Updating Page Titles Based on Language

To support translated page titles, remove static metadata from layout.tsx and implement generateMetadata in each page:

Remove from layout.tsx:

// DELETE THIS:
export const metadata = {
  title: "Create Next App",
  description: "...",
};

Add to page.tsx:

import { getMessages } from "next-intl/server";
import type { AbstractIntlMessages } from "next-intl";
 
export async function generateMetadata({
  params,
}: {
  params: { locale: string };
}) {
  const messages = (await getMessages()) as AbstractIntlMessages;
  const title = messages.tabTitles?.home;
 
  return {
    title,
  };
}

How It Works:

  • generateMetadata is a Next.js function that runs on the server
  • Fetches messages based on current locale
  • Extracts appropriate title from translation files
  • Updates page metadata dynamically

Complete Code Examples

Full Project Structure

my-nextjs-app/
├── app/
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   └── Navbar.tsx
├── i18n/
│   └── request.ts
├── messages/
│   ├── en.json
│   └── fr.json
├── next.config.ts
└── package.json

Complete i18n/request.ts

import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";
 
export default getRequestConfig(async () => {
  const cookieStore = await cookies();
  const cookieLocale = cookieStore.get("my_nextapp_locale")?.value;
 
  const locale = cookieLocale || "en";
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Complete Navbar Component

"use client";
 
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
 
export default function Navbar() {
  const [locale, setLocale] = useState("");
  const router = useRouter();
 
  useEffect(() => {
    const cookieLocale = document.cookie
      .split("; ")
      .find((row) => row.startsWith("my_nextapp_locale="))
      ?.split("=")[1];
 
    if (cookieLocale) {
      setLocale(cookieLocale);
    } else {
      const browserLocale = navigator.language.slice(0, 2);
      setLocale(browserLocale);
      document.cookie = `my_nextapp_locale=${browserLocale}; path=/`;
      router.refresh();
    }
  }, [router]);
 
  const changeLanguage = (newLocale: string) => {
    setLocale(newLocale);
    document.cookie = `my_nextapp_locale=${newLocale}; path=/`;
    router.refresh();
  };
 
  return (
    <nav className="flex items-center justify-between p-4">
      <h1 className="text-2xl font-bold">Logo</h1>
      <div className="flex gap-2">
        <button
          onClick={() => changeLanguage("en")}
          className={`rounded px-4 py-2 ${
            locale === "en" ? "bg-blue-500 text-white" : "bg-gray-200"
          }`}
        >
          EN
        </button>
        <button
          onClick={() => changeLanguage("fr")}
          className={`rounded px-4 py-2 ${
            locale === "fr" ? "bg-blue-500 text-white" : "bg-gray-200"
          }`}
        >
          FR
        </button>
      </div>
    </nav>
  );
}

Complete Page with Translations and Metadata

import { useTranslations } from "next-intl";
import { getMessages } from "next-intl/server";
import type { AbstractIntlMessages } from "next-intl";
 
export async function generateMetadata() {
  const messages = (await getMessages()) as AbstractIntlMessages;
  const title = messages.tabTitles?.home;
 
  return { title };
}
 
export default function Home() {
  const t = useTranslations("homePage");
 
  return (
    <main className="flex flex-col items-center gap-8">
      <div>
        <ol>
          <li>
            <code>{t("listOne")}</code> app/page.tsx
          </li>
          <li>{t("listTwo")}</li>
        </ol>
      </div>
    </main>
  );
}

Best Practices

1. Translation File Organization

  • Keep keys consistent across all language files
  • Use nested objects for logical grouping
  • Use descriptive key names
  • Consider creating separate files for large applications:
    messages/
    ├── en/
    │   ├── common.json
    │   ├── home.json
    │   └── about.json
    └── fr/
        ├── common.json
        ├── home.json
        └── about.json
    
  • Use a unique cookie name to avoid conflicts
  • Set appropriate path (/ for site-wide access)
  • Consider adding expiration dates for long-term storage
  • Handle cookie consent if required by regulations (GDPR, etc.)

3. Type Safety

// Create a type for your messages
type Messages = typeof import("./messages/en.json");
 
// Use it for type-safe translations
const t = useTranslations<Messages>("homePage");

4. Adding More Languages

To add a new language:

  1. Create new message file: messages/de.json
  2. Copy structure from existing file
  3. Translate all values
  4. Add button to Navbar:
    <button onClick={() => changeLanguage("de")}>DE</button>

5. Server vs Client Components

  • Server Components: Can use useTranslations directly
  • Client Components: Add 'use client' directive and use useTranslations
  • Both approaches work identically with this setup

Troubleshooting

Common Issues

1. Translations not updating:

  • Ensure router.refresh() is called after language change
  • Check that cookie is being set correctly
  • Verify JSON files have correct structure

2. TypeScript errors in next.config.ts:

  • Use @ts-expect-error comment as shown
  • This is expected and safe with next-intl

3. Cookie not persisting:

  • Verify cookie name matches in all locations
  • Check browser settings for cookie blocking
  • Ensure path=/ is set

4. Browser language not detected:

  • Check navigator.language browser compatibility
  • Fallback to default language if detection fails
  • Use .slice(0, 2) to get language code only

Conclusion

This implementation provides a clean, URL-friendly approach to internationalization in Next.js. It automatically detects user preferences while respecting their choices across sessions. The setup works seamlessly with both client and server components, making it suitable for any Next.js application architecture.

Key Advantages

  • ✅ Clean URLs (no /en or /fr prefixes)
  • ✅ Automatic browser language detection
  • ✅ Persistent user preferences
  • ✅ Works with Server and Client Components
  • ✅ Dynamic metadata support
  • ✅ Easy to add new languages
  • ✅ Type-safe with TypeScript

Additional Resources


Document Version: 1.0
Last Updated: December 2024
Next.js Version: 15.x
next-intl Version: Latest