- TypeScript 90.6%
- JavaScript 5%
- CSS 4.4%
| app | ||
| components | ||
| i18n | ||
| messages | ||
| public | ||
| .gitignore | ||
| eslint.config.mjs | ||
| middleware.ts | ||
| next.config.ts | ||
| package.json | ||
| pnpm-lock.yaml | ||
| postcss.config.mjs | ||
| README.md | ||
| tsconfig.json | ||
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
/enor/viin 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:
- The user's preferred language is stored in a browser cookie (
NEXT_LOCALE) - URLs stay clean without locale prefixes (
/app/page1instead of/en/app/page1) - 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
-
Message files are organized by feature/page with prefixed names to avoid conflicts:
messages/en/app-page1.json → Contains: { "title": "Page 1", "description": "..." } -
In
i18n/request.ts, files are imported and assigned to namespace keys:messages: { "app-page1": appPage1.default, // namespace "app-page1" → app-page1.json content } -
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:
- Cookie (
NEXT_LOCALE) - If the user previously selected a language - Accept-Language header - Browser's language preference
- Default locale - Falls back to
"en"
When a user clicks a language button in LocaleSwitcher:
- The component sets
document.cookie = "NEXT_LOCALE=vi;..." - Calls
router.refresh()to reload the page - 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
- The middleware reads the
NEXT_LOCALEcookie to determine the locale - It internally rewrites the request to include the locale (e.g.,
/app→/en/app) - Next.js needs the
[locale]dynamic segment to receive this locale parameter - The layout uses
params.localeto 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]/.
Cookie persistence
The NEXT_LOCALE cookie is set with max-age=31536000 (1 year), so the user's language preference persists across sessions.