diff --git a/examples/example-app-router-patterns/.gitignore b/examples/example-app-router-patterns/.gitignore
new file mode 100644
index 000000000..04239e7d0
--- /dev/null
+++ b/examples/example-app-router-patterns/.gitignore
@@ -0,0 +1,4 @@
+/node_modules
+/.next/
+.DS_Store
+tsconfig.tsbuildinfo
diff --git a/examples/example-app-router-patterns/README.md b/examples/example-app-router-patterns/README.md
new file mode 100644
index 000000000..6c3beabe0
--- /dev/null
+++ b/examples/example-app-router-patterns/README.md
@@ -0,0 +1,9 @@
+# example-app-router-patterns
+
+This example demonstrates various use cases and patterns for using `next-intl` with the App Router.
+
+## Deploy your own
+
+By deploying to [Vercel](https://vercel.com), you can check out the example in action. Note that you'll be prompted to create a new GitHub repository as part of this, allowing you to make subsequent changes.
+
+[](https://vercel.com/new/clone?repository-url=https://github.com/amannn/next-intl/tree/main/examples/example-app-router-patterns)
diff --git a/examples/example-app-router-patterns/eslint.config.mjs b/examples/example-app-router-patterns/eslint.config.mjs
new file mode 100644
index 000000000..144292198
--- /dev/null
+++ b/examples/example-app-router-patterns/eslint.config.mjs
@@ -0,0 +1,22 @@
+import {dirname} from 'path';
+import {fileURLToPath} from 'url';
+import {FlatCompat} from '@eslint/eslintrc';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const compat = new FlatCompat({
+ baseDirectory: __dirname
+});
+
+const eslintConfig = [
+ ...compat.extends('next/core-web-vitals', 'next/typescript'),
+ {
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/ban-ts-comment': 'off'
+ }
+ }
+];
+
+export default eslintConfig;
diff --git a/examples/example-app-router-patterns/next-env.d.ts b/examples/example-app-router-patterns/next-env.d.ts
new file mode 100644
index 000000000..830fb594c
--- /dev/null
+++ b/examples/example-app-router-patterns/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/examples/example-app-router-patterns/next.config.mjs b/examples/example-app-router-patterns/next.config.mjs
new file mode 100644
index 000000000..4678774e6
--- /dev/null
+++ b/examples/example-app-router-patterns/next.config.mjs
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+export default nextConfig;
diff --git a/examples/example-app-router-patterns/package.json b/examples/example-app-router-patterns/package.json
new file mode 100644
index 000000000..f35bd99ba
--- /dev/null
+++ b/examples/example-app-router-patterns/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "example-app-router-patterns",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "lint": "eslint src && prettier src --check",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "next": "^15.5.0",
+ "next-intl": "^4.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwindcss": "^4.1.12"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.1.0",
+ "@tailwindcss/postcss": "^4.1.12",
+ "@types/node": "^20.14.5",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "eslint": "9.11.1",
+ "eslint-config-next": "^15.5.0",
+ "postcss": "^8.5.3",
+ "prettier": "^3.3.3",
+ "prettier-plugin-organize-imports": "^4.2.0",
+ "prettier-plugin-tailwindcss": "^0.6.14",
+ "typescript": "^5.5.3"
+ },
+ "prettier": {
+ "singleQuote": true,
+ "bracketSpacing": false,
+ "trailingComma": "none",
+ "plugins": [
+ "prettier-plugin-organize-imports",
+ "prettier-plugin-tailwindcss"
+ ]
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
diff --git a/examples/example-app-router-patterns/postcss.config.mjs b/examples/example-app-router-patterns/postcss.config.mjs
new file mode 100644
index 000000000..313840505
--- /dev/null
+++ b/examples/example-app-router-patterns/postcss.config.mjs
@@ -0,0 +1,6 @@
+const config = {
+ plugins: {
+ '@tailwindcss/postcss': {}
+ }
+};
+export default config;
diff --git a/examples/example-app-router-patterns/src/app/design/DesignColorSwatch.tsx b/examples/example-app-router-patterns/src/app/design/DesignColorSwatch.tsx
new file mode 100644
index 000000000..7f6222457
--- /dev/null
+++ b/examples/example-app-router-patterns/src/app/design/DesignColorSwatch.tsx
@@ -0,0 +1,15 @@
+import clsx from 'clsx';
+
+type Props = {
+ className: string;
+ value: string;
+};
+
+export default function DesignColorSwatch({className, value}: Props) {
+ return (
+
+ );
+}
diff --git a/examples/example-app-router-patterns/src/app/design/DesignSection.tsx b/examples/example-app-router-patterns/src/app/design/DesignSection.tsx
new file mode 100644
index 000000000..433cbf251
--- /dev/null
+++ b/examples/example-app-router-patterns/src/app/design/DesignSection.tsx
@@ -0,0 +1,17 @@
+import {ReactNode} from 'react';
+
+type Props = {
+ children: ReactNode;
+ title: string;
+};
+
+export default function DesignSection({children, title}: Props) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
diff --git a/examples/example-app-router-patterns/src/app/design/page.tsx b/examples/example-app-router-patterns/src/app/design/page.tsx
new file mode 100644
index 000000000..1fcd139d5
--- /dev/null
+++ b/examples/example-app-router-patterns/src/app/design/page.tsx
@@ -0,0 +1,170 @@
+// Documentation page for the next-intl design system
+
+import DesignColorSwatch from './DesignColorSwatch';
+import DesignSection from './DesignSection';
+
+export default function DesignPage() {
+ return (
+
+
+
+ Design system
+
+
+
+
+
+
+ Blue (primary)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gray
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Title large
+
+
+ Title normal
+
+
Title description
+
+ Title caption
+
+
+ Title small
+
+
+ Headline
+
+
+ Headline caption
+
+
Body large
+
Body large muted
+
+ Body with{' '}
+
+ a link
+
+
+
Body muted
+
Label
+
+ Label muted
+
+
+ Inline code
+
+
+
+
+
+
+ Title large
+
+
+ Title normal
+
+
Title description
+
+ Title caption
+
+
+ Title small
+
+
Headline
+
+ Headline caption
+
+
Body large
+
Body large muted
+
+ Body with{' '}
+
+ a link
+
+
+
Body muted
+
Label
+
+ Label muted
+
+
+ Inline code
+
+
+
+
+
+
+
+
+
+
+ Title caption
+
+
+ Title normal
+
+
+ Title description
+
+
+
+
+
+
+ Title caption
+
+
+ Title normal
+
+
+ Title description
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/examples/example-app-router-patterns/src/app/favicon.ico b/examples/example-app-router-patterns/src/app/favicon.ico
new file mode 100644
index 000000000..4ddd8fff7
Binary files /dev/null and b/examples/example-app-router-patterns/src/app/favicon.ico differ
diff --git a/examples/example-app-router-patterns/src/app/globals.css b/examples/example-app-router-patterns/src/app/globals.css
new file mode 100644
index 000000000..73bb5b727
--- /dev/null
+++ b/examples/example-app-router-patterns/src/app/globals.css
@@ -0,0 +1,23 @@
+@import 'tailwindcss';
+
+@theme {
+ --color-white: #ffffff;
+
+ --color-gray-50: #f7f7f8;
+ --color-gray-100: #ebebef;
+ --color-gray-200: #d1d1db;
+ --color-gray-300: #a9a9bc;
+ --color-gray-400: #8a8aa3;
+ --color-gray-500: #6c6c89;
+ --color-gray-600: #55556d;
+ --color-gray-700: #3f3f50;
+ --color-gray-800: #282833;
+ --color-gray-900: #121217;
+
+ --color-blue-300: #70d2ff;
+ --color-blue-700: #008fd6;
+
+ --font-mono:
+ Monaco, ui-monospace, SFMono-Regular, Menlo, Consolas, Liberation Mono,
+ Courier New, monospace;
+}
diff --git a/examples/example-app-router-patterns/src/app/layout.tsx b/examples/example-app-router-patterns/src/app/layout.tsx
new file mode 100644
index 000000000..f2c77bbad
--- /dev/null
+++ b/examples/example-app-router-patterns/src/app/layout.tsx
@@ -0,0 +1,16 @@
+import {Inter} from 'next/font/google';
+import './globals.css';
+import {clsx} from 'clsx';
+
+const inter = Inter({
+ subsets: ['latin'],
+ variable: '--font-inter'
+});
+
+export default function RootLayout({children}: LayoutProps<'/'>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/examples/example-app-router-patterns/src/app/page.tsx b/examples/example-app-router-patterns/src/app/page.tsx
new file mode 100644
index 000000000..f5638571b
--- /dev/null
+++ b/examples/example-app-router-patterns/src/app/page.tsx
@@ -0,0 +1,5 @@
+import {redirect} from 'next/navigation';
+
+export default function HomePage() {
+ redirect('/design');
+}
diff --git a/examples/example-app-router-patterns/tsconfig.json b/examples/example-app-router-patterns/tsconfig.json
new file mode 100644
index 000000000..1638156ff
--- /dev/null
+++ b/examples/example-app-router-patterns/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "allowArbitraryExtensions": true,
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "strict": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ // See https://github.com/amannn/next-intl/pull/1509
+ "declaration": true
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/playground/.gitignore b/playground/.gitignore
new file mode 100644
index 000000000..0df02495b
--- /dev/null
+++ b/playground/.gitignore
@@ -0,0 +1,44 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# local screenshots (not committed; used to share visuals)
+/screenshots
diff --git a/playground/README.md b/playground/README.md
new file mode 100644
index 000000000..e803f8df5
--- /dev/null
+++ b/playground/README.md
@@ -0,0 +1,25 @@
+# next-intl Playground
+
+A demo site that documents `next-intl` patterns. Built on Next.js 15 with locale-prefixed routes (`/[locale]/...`) and Code Hike for code samples.
+
+## Develop
+
+```bash
+pnpm --filter playground dev
+```
+
+Open http://localhost:3000.
+
+## Test
+
+```bash
+pnpm --filter playground e2e
+```
+
+## Add a new page
+
+1. Create `src/app/[locale]///page.tsx`, `content.mdx`, and a live demo component.
+2. Add an entry to `src/lib/nav.ts`.
+3. Add string keys to `messages/{en,de}.json`.
+
+See existing examples under `src/app/[locale]/translations/`.
diff --git a/playground/components.json b/playground/components.json
new file mode 100644
index 000000000..edcaef267
--- /dev/null
+++ b/playground/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/playground/eslint.config.mjs b/playground/eslint.config.mjs
new file mode 100644
index 000000000..719cea2b5
--- /dev/null
+++ b/playground/eslint.config.mjs
@@ -0,0 +1,25 @@
+import { dirname } from "path";
+import { fileURLToPath } from "url";
+import { FlatCompat } from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+});
+
+const eslintConfig = [
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
+ {
+ ignores: [
+ "node_modules/**",
+ ".next/**",
+ "out/**",
+ "build/**",
+ "next-env.d.ts",
+ ],
+ },
+];
+
+export default eslintConfig;
diff --git a/playground/mdx-components.tsx b/playground/mdx-components.tsx
new file mode 100644
index 000000000..49eea7a75
--- /dev/null
+++ b/playground/mdx-components.tsx
@@ -0,0 +1,6 @@
+import type { MDXComponents } from 'mdx/types';
+import { Code } from '@/components/code/code';
+
+export function useMDXComponents(components: MDXComponents): MDXComponents {
+ return { Code, ...components };
+}
diff --git a/playground/messages/de.json b/playground/messages/de.json
new file mode 100644
index 000000000..ae960d1dc
--- /dev/null
+++ b/playground/messages/de.json
@@ -0,0 +1,98 @@
+{
+ "Layout": {
+ "title": "next-intl Playground",
+ "tagline": "Übersetzungen, Formatierung, Routing und Patterns mit Next.js.",
+ "examplesLabel": "Beispiele"
+ },
+ "Nav": {
+ "translations": "Übersetzungen",
+ "formatting": "Formatierung",
+ "routing": "Routing",
+ "patterns": "Patterns",
+ "serverComponents": "Server-Komponenten",
+ "serverComponentsDesc": "Übersetzte Strings in async Server Components lesen — null Client-JS.",
+ "clientComponents": "Client-Komponenten",
+ "clientComponentsDesc": "Übersetzungen aus Client Components für interaktive Inhalte verwenden.",
+ "dates": "Datum & Uhrzeit",
+ "datesDesc": "Datum und Uhrzeit für die aktive Sprache mit useFormatter formatieren.",
+ "numbers": "Zahlen",
+ "numbersDesc": "Zahlen, Währungen und Prozente formatieren — Trennzeichen wechseln je nach Sprache.",
+ "explorer": "Explorer",
+ "explorerDesc": "useFormatter() mit Live-Optionen steuern und das Ergebnis sprachübergreifend vergleichen.",
+ "mixedRouting": "Gemischtes Routing",
+ "mixedRoutingDesc": "Mit und ohne Sprachpräfix — die drei localePrefix-Modi nebeneinander.",
+ "localeSwitcher": "Sprachumschalter",
+ "localeSwitcherDesc": "Aktuellen Pfad unter einer anderen Sprache erneut aufrufen und Params erhalten.",
+ "browserLanguage": "Browser-Sprache",
+ "browserLanguageDesc": "Bevorzugte Sprache der Nutzer:innen aus dem Accept-Language-Header via next-intl-Middleware ermitteln.",
+ "cacheComponents": "Cache Components",
+ "cacheComponentsDesc": "next-intl mit Next's Cache Components kombinieren — 'use cache', cacheLife, cacheTag.",
+ "soon": "demnächst"
+ },
+ "ServerDemo": {
+ "title": "Server-Komponenten",
+ "greeting": "Hallo, Welt!"
+ },
+ "ClientDemo": {
+ "title": "Client-Komponenten",
+ "label": "Dein Name",
+ "placeholder": "Frodo",
+ "greeting": "Hallo, {name}!"
+ },
+ "DatesDemo": {
+ "dateStyle": "Datumsstil",
+ "timeStyle": "Uhrzeitstil"
+ },
+ "NumbersDemo": {
+ "value": "Wert",
+ "style": "Stil",
+ "currency": "Währung"
+ },
+ "MixedRoutingDemo": {
+ "currentLocale": "Aktuelle Sprache",
+ "alwaysNote": "Jede Sprache, auch die Standardsprache, erhält ein Präfix.",
+ "asNeededNote": "Die Standardsprache bleibt ohne Präfix; andere bekommen eines.",
+ "neverNote": "Kein Präfix — die Sprache wird per Cookie oder Header ermittelt."
+ },
+ "LocaleSwitcherDemo": {
+ "hint": "Wähle eine Sprache — nutzt next-intls typisiertes Link mit locale-Prop; das URL-Präfix wechselt und das Layout rendert in der neuen Sprache neu."
+ },
+ "CacheComponents": {
+ "boundaryLabel": "Demnächst",
+ "kicker": "Patterns",
+ "title": "Cache Components",
+ "intro": "Demnächst. Hier ist, was diese Seite zeigen wird.",
+ "useCacheTitle": "'use cache' auf getTranslations",
+ "useCacheBody": "Sieh dir an, wie eine gecachte Server Component übersetzte Inhalte über Anfragen hinweg mit einem sprachspezifischen Cache-Key wiederverwendet.",
+ "cacheLifeTitle": "cacheLife-Profile",
+ "cacheLifeBody": "Pinne einen übersetzten Abschnitt auf Sekunden, Minuten oder Stunden — und beobachte die Invalidierung in Echtzeit.",
+ "cacheTagTitle": "cacheTag für gezielte Revalidierung",
+ "cacheTagBody": "Versieh Übersetzungen mit einem Schlüssel und löse updateTag aus einer Server Action aus, um genau diese Fläche neu zu laden.",
+ "tryExplorer": "Probier in der Zwischenzeit den Explorer",
+ "readRfc": "use-cache-RFC lesen ↗"
+ },
+ "NotFound": {
+ "kicker": "Fehler 404 · Seite nicht gefunden",
+ "title": "Dieser Route fehlt ein Übersetzungsschlüssel.",
+ "subtitle": "Die aufgerufene URL passt zu keiner Seite im Playground. Solange du hier bist — tippe etwas in die Felder unten, next-intl formatiert es live in deiner aktuellen Sprache.",
+ "playgroundLabel": "Solange du hier bist",
+ "takeMeBack": "Bring mich woandershin",
+ "greeting": "Hi {name},",
+ "scoreLine": "dein In-404er-stolpern-Score liegt bei {score}.",
+ "nameLabel": "Dein Name",
+ "scoreLabel": "404-Score",
+ "placeholder": "Frodo",
+ "hint": "Formatiert mit useFormatter() und useTranslations() in der Sprache {locale} — dieselben APIs wie überall sonst im Playground."
+ },
+ "BrowserLanguageDemo": {
+ "acceptLanguage": "Accept-Language-Header",
+ "noHeader": "(kein Header gesendet)",
+ "preferences": "Geparste Präferenzen",
+ "resolved": "Ermittelte Sprache",
+ "matchKind": "Trefferart",
+ "exactMatch": "exakt",
+ "languageMatch": "Sprache",
+ "fallbackMatch": "Fallback",
+ "currentNote": "Du siehst gerade die {locale}-Version, weil das URL-Präfix Vorrang vor dem Header hat. Öffne / ohne Präfix, um die Header-Erkennung in Aktion zu sehen."
+ }
+}
diff --git a/playground/messages/en.json b/playground/messages/en.json
new file mode 100644
index 000000000..13f0260aa
--- /dev/null
+++ b/playground/messages/en.json
@@ -0,0 +1,98 @@
+{
+ "Layout": {
+ "title": "next-intl Playground",
+ "tagline": "Translations, formatting, routing, and patterns with Next.js.",
+ "examplesLabel": "Examples"
+ },
+ "Nav": {
+ "translations": "Translations",
+ "formatting": "Formatting",
+ "routing": "Routing",
+ "patterns": "Patterns",
+ "serverComponents": "Server components",
+ "serverComponentsDesc": "Read translated strings inside async Server Components — zero client JS.",
+ "clientComponents": "Client components",
+ "clientComponentsDesc": "Use translations from Client Components for interactive content.",
+ "dates": "Dates",
+ "datesDesc": "Format dates and times for the active locale with useFormatter.",
+ "numbers": "Numbers",
+ "numbersDesc": "Format numbers, currencies, and percentages — separators flip per locale.",
+ "explorer": "Explorer",
+ "explorerDesc": "Drive useFormatter() with live options and compare output across locales.",
+ "mixedRouting": "Mixed routing",
+ "mixedRoutingDesc": "Locale-prefixed vs unprefixed paths — see the three localePrefix modes side by side.",
+ "localeSwitcher": "Locale switcher",
+ "localeSwitcherDesc": "Replay the current path under a different locale while preserving params.",
+ "browserLanguage": "Browser language",
+ "browserLanguageDesc": "Detect the user's preferred locale from the Accept-Language header via next-intl middleware.",
+ "cacheComponents": "Cache components",
+ "cacheComponentsDesc": "Combine next-intl with Next's Cache Components — 'use cache', cacheLife, cacheTag.",
+ "soon": "soon"
+ },
+ "ServerDemo": {
+ "title": "Server Components",
+ "greeting": "Hello, world!"
+ },
+ "ClientDemo": {
+ "title": "Client Components",
+ "label": "Your name",
+ "placeholder": "Frodo",
+ "greeting": "Hello, {name}!"
+ },
+ "DatesDemo": {
+ "dateStyle": "Date style",
+ "timeStyle": "Time style"
+ },
+ "NumbersDemo": {
+ "value": "Value",
+ "style": "Style",
+ "currency": "Currency"
+ },
+ "MixedRoutingDemo": {
+ "currentLocale": "Current locale",
+ "alwaysNote": "Every locale, including the default, carries a prefix.",
+ "asNeededNote": "Default locale stays bare; others get a prefix.",
+ "neverNote": "No prefix anywhere — locale is resolved via cookie or header."
+ },
+ "LocaleSwitcherDemo": {
+ "hint": "Pick a locale — uses next-intl's typed Link with a locale prop; the URL prefix updates and the layout re-renders with the new locale."
+ },
+ "CacheComponents": {
+ "boundaryLabel": "Coming soon",
+ "kicker": "Patterns",
+ "title": "Cache components",
+ "intro": "Coming soon. Here's what this page will demo.",
+ "useCacheTitle": "'use cache' on getTranslations",
+ "useCacheBody": "See how a cached Server Component reuses translated content across requests with a locale-scoped cache key.",
+ "cacheLifeTitle": "cacheLife profiles",
+ "cacheLifeBody": "Pin a translated section to seconds, minutes, or hours — and watch invalidation in real time.",
+ "cacheTagTitle": "cacheTag for surgical revalidation",
+ "cacheTagBody": "Tag translations with a key, then trigger updateTag from a Server Action to refresh just that surface.",
+ "tryExplorer": "Try the Explorer in the meantime",
+ "readRfc": "Read the use cache RFC ↗"
+ },
+ "NotFound": {
+ "kicker": "Error 404 · Page not found",
+ "title": "This route is missing a translation key.",
+ "subtitle": "The URL you tried doesn't match any page in the playground. While you're here, type something into the boxes below — next-intl will format it live in your current locale.",
+ "playgroundLabel": "While you're here",
+ "takeMeBack": "Take me somewhere real",
+ "greeting": "Hi {name},",
+ "scoreLine": "your wandering-into-404s score is {score}.",
+ "nameLabel": "Your name",
+ "scoreLabel": "404 score",
+ "placeholder": "Frodo",
+ "hint": "Formatted via useFormatter() and useTranslations() in the {locale} locale — same APIs the rest of the playground uses."
+ },
+ "BrowserLanguageDemo": {
+ "acceptLanguage": "Accept-Language header",
+ "noHeader": "(no header sent)",
+ "preferences": "Parsed preferences",
+ "resolved": "Resolved locale",
+ "matchKind": "Match kind",
+ "exactMatch": "exact",
+ "languageMatch": "language",
+ "fallbackMatch": "fallback",
+ "currentNote": "You're currently viewing the {locale} version because the URL prefix wins over the header. Visit / without a prefix to see header-based detection take effect."
+ }
+}
diff --git a/playground/next.config.ts b/playground/next.config.ts
new file mode 100644
index 000000000..8f9a5c733
--- /dev/null
+++ b/playground/next.config.ts
@@ -0,0 +1,28 @@
+import type { NextConfig } from 'next';
+import createMDX from '@next/mdx';
+import { remarkCodeHike, recmaCodeHike, type CodeHikeConfig } from 'codehike/mdx';
+import createNextIntlPlugin from 'next-intl/plugin';
+
+const chConfig: CodeHikeConfig = {
+ components: { code: 'Code' },
+};
+
+const withMDX = createMDX({
+ extension: /\.mdx?$/,
+ options: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ remarkPlugins: [[remarkCodeHike as any, chConfig]],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ recmaPlugins: [[recmaCodeHike as any, chConfig]],
+ jsx: true,
+ },
+});
+
+const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
+
+const nextConfig: NextConfig = {
+ pageExtensions: ['ts', 'tsx', 'mdx'],
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export default withNextIntl(withMDX(nextConfig as any) as any);
diff --git a/playground/package.json b/playground/package.json
new file mode 100644
index 000000000..b460c3a9c
--- /dev/null
+++ b/playground/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "playground",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint",
+ "e2e": "playwright test"
+ },
+ "dependencies": {
+ "@mdx-js/loader": "^3.1.0",
+ "@mdx-js/react": "^3.1.0",
+ "@next/mdx": "^15.5.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-slot": "^1.2.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "codehike": "^1.0.7",
+ "lucide-react": "^0.545.0",
+ "next": "15.5.15",
+ "next-intl": "^4.9.1",
+ "next-themes": "^0.4.6",
+ "radix-ui": "^1.4.3",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "tailwind-merge": "^3.3.1",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3",
+ "@playwright/test": "^1.50.0",
+ "@tailwindcss/postcss": "^4",
+ "@types/mdx": "^2.0.13",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "eslint": "^9",
+ "eslint-config-next": "15.5.15",
+ "tailwindcss": "^4",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5"
+ }
+}
diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts
new file mode 100644
index 000000000..4f509126d
--- /dev/null
+++ b/playground/playwright.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ use: { baseURL: 'http://localhost:3000' },
+ webServer: {
+ command: 'pnpm dev',
+ port: 3000,
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/playground/postcss.config.mjs b/playground/postcss.config.mjs
new file mode 100644
index 000000000..c7bcb4b1e
--- /dev/null
+++ b/playground/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/playground/public/file.svg b/playground/public/file.svg
new file mode 100644
index 000000000..004145cdd
--- /dev/null
+++ b/playground/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/playground/public/globe.svg b/playground/public/globe.svg
new file mode 100644
index 000000000..567f17b0d
--- /dev/null
+++ b/playground/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/playground/public/next.svg b/playground/public/next.svg
new file mode 100644
index 000000000..5174b28c5
--- /dev/null
+++ b/playground/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/playground/public/vercel.svg b/playground/public/vercel.svg
new file mode 100644
index 000000000..770539603
--- /dev/null
+++ b/playground/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/playground/public/window.svg b/playground/public/window.svg
new file mode 100644
index 000000000..b2b2a44f6
--- /dev/null
+++ b/playground/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/playground/src/app/[locale]/formatting/dates/content.mdx b/playground/src/app/[locale]/formatting/dates/content.mdx
new file mode 100644
index 000000000..e84a83686
--- /dev/null
+++ b/playground/src/app/[locale]/formatting/dates/content.mdx
@@ -0,0 +1,22 @@
+import {TwoColumn} from '@/components/playground/two-column';
+
+
+
+- Call `useFormatter()` to get a formatter bound to the active locale.
+- `format.dateTime(date, options)` accepts the same options as `Intl.DateTimeFormat` — `dateStyle`, `timeStyle`, `weekday`, `year`, ...
+- Use `useNow()` to get a stable "now" reference that hydrates safely across server and client.
+
+```tsx app/page.tsx
+'use client';
+import {useFormatter, useNow} from 'next-intl';
+
+export function Today() {
+ const format = useFormatter();
+ const now = useNow();
+
+ // !mark
+ return {format.dateTime(now, {dateStyle: 'long', timeStyle: 'short'})}
;
+}
+```
+
+
diff --git a/playground/src/app/[locale]/formatting/dates/dates-example.tsx b/playground/src/app/[locale]/formatting/dates/dates-example.tsx
new file mode 100644
index 000000000..01586c9e6
--- /dev/null
+++ b/playground/src/app/[locale]/formatting/dates/dates-example.tsx
@@ -0,0 +1,84 @@
+'use client';
+
+import {useState} from 'react';
+import {useFormatter, useNow, useTranslations} from 'next-intl';
+import {Label} from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select';
+
+type Style = 'full' | 'long' | 'medium' | 'short';
+
+export function DatesExample() {
+ const t = useTranslations('DatesDemo');
+ const format = useFormatter();
+ const now = useNow();
+ const [dateStyle, setDateStyle] = useState