No description
  • TypeScript 90.6%
  • JavaScript 5%
  • CSS 4.4%
Find a file
2025-12-08 22:36:18 +07:00
app Refactor internationalization message files to use prefixed names for app pages, removing old page1 and page2 files. Update imports and usage in components accordingly. 2025-12-08 22:36:18 +07:00
components first commit 2025-12-08 22:11:16 +07:00
i18n Refactor internationalization message files to use prefixed names for app pages, removing old page1 and page2 files. Update imports and usage in components accordingly. 2025-12-08 22:36:18 +07:00
messages Refactor internationalization message files to use prefixed names for app pages, removing old page1 and page2 files. Update imports and usage in components accordingly. 2025-12-08 22:36:18 +07:00
public first commit 2025-12-08 22:11:16 +07:00
.gitignore first commit 2025-12-08 22:11:16 +07:00
eslint.config.mjs first commit 2025-12-08 22:11:16 +07:00
middleware.ts first commit 2025-12-08 22:11:16 +07:00
next.config.ts first commit 2025-12-08 22:11:16 +07:00
package.json first commit 2025-12-08 22:11:16 +07:00
pnpm-lock.yaml first commit 2025-12-08 22:11:16 +07:00
postcss.config.mjs first commit 2025-12-08 22:11:16 +07:00
README.md Refactor internationalization message files to use prefixed names for app pages, removing old page1 and page2 files. Update imports and usage in components accordingly. 2025-12-08 22:36:18 +07:00
tsconfig.json first commit 2025-12-08 22:11:16 +07:00

Next.js Internationalization with next-intl (Cookie-Based)

This project demonstrates how to implement cookie-based internationalization in Next.js using next-intl, where the locale is stored in a cookie instead of being part of the URL.

Features

  • Cookie-based locale switching (no /en or /vi in URLs)
  • Separate translation files per page/feature
  • TypeScript support
  • Server and client component support
  • Locale switcher component

Tech Stack

  • Next.js 16 (App Router)
  • React 19
  • TypeScript
  • next-intl
  • Tailwind CSS v4
  • Ant Design 6

Getting Started

# Install dependencies
pnpm install

# Start development server
pnpm dev

# Build for production
pnpm build

Open http://localhost:3000 to see the app.


How Internationalization Works

Overview

This project uses a cookie-based approach for internationalization, which means:

  1. The user's preferred language is stored in a browser cookie (NEXT_LOCALE)
  2. URLs stay clean without locale prefixes (/app/page1 instead of /en/app/page1)
  3. Language can be switched without changing the URL

Project Structure

├── app/
│   ├── layout.tsx                 # Root layout (passthrough)
│   ├── globals.css                # Global styles
│   └── [locale]/                  # Locale segment (hidden from URL)
│       ├── layout.tsx             # Locale layout with providers
│       ├── page.tsx               # Landing page (/)
│       └── app/
│           ├── page.tsx           # Dashboard (/app)
│           ├── page1/
│           │   └── page.tsx       # Page 1 (/app/page1)
│           └── page2/
│               └── page.tsx       # Page 2 (/app/page2)
├── components/
│   └── LocaleSwitcher.tsx         # Language switcher component
├── i18n/
│   ├── routing.ts                 # Locale configuration
│   └── request.ts                 # Server-side message loading
├── messages/
│   ├── en/                        # English translations
│   │   ├── common.json            # Shared translations
│   │   ├── landing.json           # Landing page (/)
│   │   ├── app.json               # Dashboard (/app)
│   │   ├── app-page1.json         # Page 1 (/app/page1)
│   │   └── app-page2.json         # Page 2 (/app/page2)
│   └── vi/                        # Vietnamese translations
│       ├── common.json
│       ├── landing.json
│       ├── app.json
│       ├── app-page1.json
│       └── app-page2.json
└── middleware.ts                  # Locale detection middleware

Step-by-Step Implementation Guide

Step 1: Install Dependencies

pnpm add next-intl

Step 2: Configure Routing (i18n/routing.ts)

This file defines the supported locales and configures next-intl to use cookies instead of URL prefixes.

import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";

// Define supported locales
export const locales = ["en", "vi"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";

export const routing = defineRouting({
  locales,
  defaultLocale,
  localePrefix: "never", // KEY: This hides locale from URL
});

// Export navigation utilities
export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing);

Key point: localePrefix: "never" tells next-intl to never show the locale in the URL. The locale is determined by a cookie instead.

Step 3: Configure Request Handling (i18n/request.ts)

This file loads translation messages on the server side.

import { getRequestConfig } from "next-intl/server";
import { routing, type Locale } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  // Get locale from the request (determined by middleware)
  let locale = await requestLocale;

  // Fallback to default if invalid
  if (!locale || !routing.locales.includes(locale as Locale)) {
    locale = routing.defaultLocale;
  }

  // Load all message files for this locale
  // Using prefixed names to avoid conflicts (e.g., app-page1 for /app/page1)
  const [common, landing, app, appPage1, appPage2] = await Promise.all([
    import(`../messages/${locale}/common.json`),
    import(`../messages/${locale}/landing.json`),
    import(`../messages/${locale}/app.json`),
    import(`../messages/${locale}/app-page1.json`),
    import(`../messages/${locale}/app-page2.json`),
  ]);

  // Return merged messages with namespace keys
  return {
    locale,
    messages: {
      common: common.default,
      landing: landing.default,
      app: app.default,
      "app-page1": appPage1.default,
      "app-page2": appPage2.default,
    },
  };
});

Step 4: Configure Next.js (next.config.ts)

import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin();

const nextConfig: NextConfig = {
  // your other config
};

export default withNextIntl(nextConfig);

Step 5: Create Middleware (middleware.ts)

The middleware detects the user's locale from cookies and handles routing.

import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";

export default createMiddleware(routing);

export const config = {
  // Match all paths except static files and API routes
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

Step 6: Create Root Layout (app/layout.tsx)

The root layout is a simple passthrough.

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return children;
}

Step 7: Create Locale Layout (app/[locale]/layout.tsx)

This layout wraps all pages with the internationalization provider.

import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation";

type Props = {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
};

// Generate static params for all locales
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;

  // Validate locale
  if (!routing.locales.includes(locale as "en" | "vi")) {
    notFound();
  }

  // Get messages for this locale
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Step 8: Create Translation Files

Create separate JSON files for each page/feature:

messages/en/landing.json

{
  "title": "Welcome to Our App",
  "description": "A modern application built with Next.js.",
  "getStarted": "Get Started"
}

messages/vi/landing.json

{
  "title": "Chào mừng đến với Ứng dụng",
  "description": "Một ứng dụng hiện đại được xây dựng với Next.js.",
  "getStarted": "Bắt đầu"
}

Step 9: Use Translations in Pages

import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";

export default function LandingPage() {
  // Use the "landing" namespace to access landing.json translations
  const t = useTranslations("landing");

  return (
    <div>
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
      <Link href="/app">{t("getStarted")}</Link>
    </div>
  );
}

Step 10: Create Locale Switcher Component

"use client";

import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { Locale, locales } from "@/i18n/routing";

const localeNames: Record<Locale, string> = {
  en: "English",
  vi: "Tiếng Việt",
};

export default function LocaleSwitcher() {
  const locale = useLocale() as Locale;
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  function onSelectChange(nextLocale: Locale) {
    startTransition(() => {
      // Set cookie and refresh page
      document.cookie = `NEXT_LOCALE=${nextLocale};path=/;max-age=31536000`;
      router.refresh();
    });
  }

  return (
    <div>
      {locales.map((l) => (
        <button
          key={l}
          onClick={() => onSelectChange(l)}
          disabled={isPending || locale === l}
        >
          {localeNames[l]}
        </button>
      ))}
    </div>
  );
}

How Message Namespaces Work

The Connection Between Files and Code

  1. Message files are organized by feature/page with prefixed names to avoid conflicts:

    messages/en/app-page1.json  →  Contains: { "title": "Page 1", "description": "..." }
    
  2. In i18n/request.ts, files are imported and assigned to namespace keys:

    messages: {
      "app-page1": appPage1.default,  // namespace "app-page1" → app-page1.json content
    }
    
  3. In your page, use useTranslations() with the namespace:

    const t = useTranslations("app-page1");  // Access app-page1.json
    t("title");       // Returns "Page 1"
    t("description"); // Returns the description
    

Visual Flow

messages/en/app-page1.json      i18n/request.ts              Page Component
        │                              │                            │
        │    { "title": "Page 1" }     │                            │
        │                              │                            │
        └──────────────────────────────┼────────────────────────────┘
                                       │
                                       ▼
                              messages: {
                                "app-page1": { title: "Page 1" }
                              }
                                       │
                                       ▼
                      useTranslations("app-page1") → t("title") → "Page 1"

Adding a New Page with Translations

Follow these steps to add a new page:

1. Create the translation files

Use prefixed names based on the route path (e.g., app-newpage for /app/newpage):

# Create English translations
echo '{
  "title": "My New Page",
  "description": "This is my new page."
}' > messages/en/app-newpage.json

# Create Vietnamese translations
echo '{
  "title": "Trang Mới",
  "description": "Đây là trang mới của tôi."
}' > messages/vi/app-newpage.json

2. Register the namespace in i18n/request.ts

// Add to the imports
const [common, landing, app, appPage1, appPage2, appNewpage] = await Promise.all([
  import(`../messages/${locale}/common.json`),
  import(`../messages/${locale}/landing.json`),
  import(`../messages/${locale}/app.json`),
  import(`../messages/${locale}/app-page1.json`),
  import(`../messages/${locale}/app-page2.json`),
  import(`../messages/${locale}/app-newpage.json`),  // Add this
]);

// Add to the messages object
return {
  locale,
  messages: {
    common: common.default,
    landing: landing.default,
    app: app.default,
    "app-page1": appPage1.default,
    "app-page2": appPage2.default,
    "app-newpage": appNewpage.default,  // Add this
  },
};

3. Create the page component

// app/[locale]/app/newpage/page.tsx
import { useTranslations } from "next-intl";
import LocaleSwitcher from "@/components/LocaleSwitcher";

export default function NewPage() {
  const t = useTranslations("app-newpage");

  return (
    <div>
      <LocaleSwitcher />
      <h1>{t("title")}</h1>
      <p>{t("description")}</p>
    </div>
  );
}

4. Access at /app/newpage

The page is now available at /app/newpage with full i18n support.


How Locale Detection Works

The middleware determines the locale in this priority order:

  1. Cookie (NEXT_LOCALE) - If the user previously selected a language
  2. Accept-Language header - Browser's language preference
  3. Default locale - Falls back to "en"

When a user clicks a language button in LocaleSwitcher:

  1. The component sets document.cookie = "NEXT_LOCALE=vi;..."
  2. Calls router.refresh() to reload the page
  3. Middleware reads the cookie and serves the Vietnamese version

Important Notes

All messages are loaded on every page

Currently, all translation files are imported in i18n/request.ts and sent to every page. This is simple but may not be optimal for large applications.

For better performance with many translations:

  • Consider lazy loading per route
  • Use dynamic imports based on the current path
  • Implement code splitting for translation bundles

The [locale] folder is still required

Even though the locale isn't shown in the URL, you cannot remove the [locale] folder. Here's why:

How it works internally:

User sees:     /app/page1
Internal:      /en/app/page1  (rewritten by middleware)
File system:   app/[locale]/app/page1/page.tsx
  1. The middleware reads the NEXT_LOCALE cookie to determine the locale
  2. It internally rewrites the request to include the locale (e.g., /app/en/app)
  3. Next.js needs the [locale] dynamic segment to receive this locale parameter
  4. The layout uses params.locale to load the correct messages

What localePrefix: "never" actually does:

It only hides the locale from the user-visible URL. Internally, Next.js still routes through [locale].

Without the [locale] folder, next-intl cannot:

  • Pass the locale to your layouts/pages via params
  • Know which locale's messages to load
  • Generate static pages for each locale

So your pages must be inside app/[locale]/.

The NEXT_LOCALE cookie is set with max-age=31536000 (1 year), so the user's language preference persists across sessions.


References