A production-ready boilerplate for multilingual Next.js applications. Built with Next.js 16, React 19, next-intl 4, and shadcn/ui — with locale-driven currency and date formatting, RTL support, cookie-based theme SSR, and full SEO.
Author: Sovers Tonmoy Pandey (S0vers) · GitHub · @s0ver5
Live demo: next-app-i18n-starter.vercel.app
| Area | What's included |
|---|---|
| Framework | Next.js 16 App Router, Server Components, Turbopack dev server |
| i18n | next-intl 4 — ICU messages, useFormatter, locale-driven currency/timezone |
| Languages | English, Arabic (RTL), Chinese, Spanish, Japanese |
| Formatting | Currency, dates, compact numbers, relative time — all driven by locale |
| UI | shadcn/ui components, Tailwind CSS 4, light/dark theme |
| RTL | Automatic dir="rtl" for Arabic + OmitRTL utility for LTR islands |
| SEO | metadataBase, hreflang, JSON-LD, dynamic sitemap/robots, OG image |
| DX | TypeScript, typed translation keys via global.d.ts, ESLint flat config |
# Clone
git clone https://github.com/S0vers/next-app-i18n-starter.git
cd next-app-i18n-starter
# Install
bun install
# Optional: set production URL for local SEO preview
cp .env.example .env.local
# Edit .env.local → NEXT_PUBLIC_SITE_URL=https://your-domain.com
# Run
bun dev
Open http://localhost:3000. Use the language switcher in the header to see translations and regional formatting update.
next-app-i18n-starter/
├── dictionary/ # Translation JSON files
│ ├── en.json # English (TypeScript source of truth)
│ ├── ar.json # Arabic
│ ├── zh.json # Chinese
│ ├── es.json # Spanish
│ └── ja.json # Japanese
├── public/
│ ├── llms.txt # Machine-readable context for AI tools
│ ├── og-image.png # Open Graph image (1200×630)
│ └── favicon.ico
├── src/
│ ├── app/
│ │ ├── [locale]/ # All pages are locale-scoped
│ │ │ ├── layout.tsx # Metadata, theme SSR, providers
│ │ │ ├── page.tsx # Home + JSON-LD structured data
│ │ │ ├── not-found.tsx # Localized 404
│ │ │ └── [...rest]/ # Catch-all → not-found
│ │ ├── globals.css # Tailwind + CSS variables
│ │ ├── robots.ts # Dynamic robots.txt
│ │ └── sitemap.ts # Sitemap with hreflang alternates
│ ├── components/
│ │ ├── pages/HomeIndex.tsx # Landing page (hero + tabs)
│ │ ├── LocalizationTab.tsx # Locale formatting demo
│ │ ├── LanguageSwitcher.tsx # Locale dropdown
│ │ ├── ModeToggle.tsx # Light/dark toggle
│ │ ├── OmmitRlt.tsx # OmitRTL utility
│ │ ├── theme-provider.tsx # Client theme context
│ │ └── ui/ # shadcn/ui primitives
│ ├── i18n/
│ │ ├── request.ts # getRequestConfig (core i18n setup)
│ │ ├── routing.ts # Locales + URL prefix strategy
│ │ ├── regional.ts # Per-locale currency & timezone
│ │ └── navigation.ts # Localized Link, useRouter, getPathname
│ ├── lib/
│ │ ├── site.ts # Site URL, author, SEO constants
│ │ ├── theme.ts # Theme cookie helpers
│ │ └── utils.ts # cn() class merge helper
│ └── proxy.ts # next-intl proxy (Next.js 16)
├── .env.example
├── global.d.ts # Typed IntlMessages from en.json
├── next.config.ts
├── package.json
└── tsconfig.json
This template uses next-intl with the App Router pattern. All i18n configuration flows through three files:
**src/i18n/routing.ts** — which locales exist and how URLs are shaped**src/i18n/request.ts** — per-request config (messages, timezone, formats)**src/i18n/navigation.ts** — locale-aware navigation wrappersConfigured in src/i18n/routing.ts:
export const routing = defineRouting({
locales: ["en", "ar", "zh", "es", "ja"],
defaultLocale: "en",
localeDetection: true,
localePrefix: "as-needed",
});
With localePrefix: "as-needed":
| Locale | URL | Notes |
|---|---|---|
en |
/ |
Default locale has no prefix |
ar |
/ar |
|
zh |
/zh |
|
es |
/es |
|
ja |
/ja |
src/proxy.ts runs on every request to detect locale from URL, cookie, or Accept-Language header.
Always use navigation from @/i18n/navigation, not next/link or next/navigation directly:
import { Link, useRouter, usePathname } from "@/i18n/navigation";
// Switch locale
const router = useRouter();
const pathname = usePathname();
router.replace(pathname, { locale: "ar" });
Currency, dates, and time zones are not user-configurable dropdowns — they follow the active locale. This is the recommended next-intl pattern for regional formatting.
**src/i18n/regional.ts** maps each locale to defaults:
| Locale | Currency | Time zone |
|---|---|---|
en |
USD | America/New_York |
ar |
SAR | Asia/Riyadh |
zh |
CNY | Asia/Shanghai |
es |
EUR | Europe/Madrid |
ja |
JPY | Asia/Tokyo |
**src/i18n/request.ts** applies them on every request:
export default getRequestConfig(async ({ requestLocale }) => {
const locale = /* validated against routing.locales */;
return {
locale,
timeZone: resolveTimeZone(locale),
now: new Date(),
formats: createRegionalFormats(resolveCurrency(locale)),
messages: (await import(`../../dictionary/${locale}.json`)).default,
};
});
In components, use next-intl hooks:
"use client";
import { useFormatter, useTimeZone, useNow } from "next-intl";
const format = useFormatter();
const now = useNow({ updateInterval: 30_000 });
format.number(29.99, "price"); // → "$29.99" (en) or "¥30" (ja)
format.dateTime(now, "long"); // → locale + timezone aware
format.relativeTime(twoHoursAgo, now); // → "2 hours ago"
In translation messages, use ICU syntax:
{
"priceMessage": "This product costs {price, number, currency}",
"usersCount": "{count, number, compact} users"
}
The Localization tab on the home page demonstrates all of this. Switch language in the header — prices and dates update instantly.
All strings live in dictionary/{locale}.json. Namespaces:
| Namespace | Used for |
|---|---|
Index |
Landing page UI, tabs, installation steps |
Footer |
Copyright, links |
Metadata |
SEO title, description, keywords (generateMetadata) |
Localization |
Formatting demo tab labels |
TypeScript enforces key consistency via global.d.ts:
import en from "./dictionary/en.json";
type IntlMessages = typeof en;
When you add a key to en.json, TypeScript will error until you add it to all other locale files.
Server Component (page or layout):
import { setRequestLocale, getTranslations } from "next-intl/server";
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale); // Required for static rendering
const t = await getTranslations({ locale, namespace: "Index" });
return <h1>{t("title")}</h1>;
}
Client Component:
"use client";
import { useTranslations } from "next-intl";
export function MyComponent() {
const t = useTranslations("Index");
return
{t("description")}
;
}
The root layout wraps children in NextIntlClientProvider with messages, timeZone, and now from the server.
Example: adding French (fr)
bash
cp dictionary/en.json dictionary/fr.json
# Translate all values in fr.jsonsrc/i18n/routing.ts:
ts
locales: ["en", "ar", "zh", "es", "ja", "fr"],src/i18n/regional.ts:
ts
fr: { currency: "EUR", timeZone: "Europe/Paris" },src/components/LanguageSwitcher.tsx:
ts
fr: "Français",src/lib/site.ts:
ts
fr: "fr_FR",bun run build// src/app/[locale]/about/page.tsx
import { setRequestLocale, getTranslations } from "next-intl/server";
export default async function AboutPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations({ locale, namespace: "About" });
return <h1>{t("title")}</h1>;
}
Add "About" namespace to every dictionary/*.json. Link to it with:
import { Link } from "@/i18n/navigation";
<Link href="https://github.com/S0vers/next-app-i18n-starter/raw/main/about">{t("aboutLink")}</Link>
Arabic sets dir="rtl" on <html>. Some content (code, terminal commands, logos, formatted numbers) should stay left-to-right.
Wrap those elements with OmitRTL:
import OmitRTL from "@/components/OmmitRlt";
function CodeBlock({ code }: { code: string }) {
return (
<OmitRTL omitRTL>
<pre><code>{code}</code></pre>
</OmitRTL>
);
}
The same utility is published as [react-omit-rtl](https://www.npmjs.com/package/react-omit-rtl) on npm.
Light/dark mode without flash-of-unstyled-content and without <script> tags (React 19 compatible).
How it works:
layout.tsx) reads the theme cookie and sets className="light" or "dark" on <html> before paint.theme-provider.tsx) syncs toggles to cookie + localStorage via useSyncExternalStore for system preference.ModeToggle.tsx) switches between light and dark.No blocking scripts. No next-themes dependency.
This template ships with a complete, locale-aware SEO setup using the Next.js 16 Metadata API, next-intl URL helpers, JSON-LD structured data, and dynamic sitemap/robots generation. Everything is designed to work correctly with localePrefix: "as-needed" routing.
┌─────────────────────────────────────────────────────────────────┐
│ src/lib/site.ts │
│ siteConfig.url, author, openGraphLocales │
└──────────────────────────┬──────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
layout.tsx page.tsx sitemap.ts / robots.ts
generateMetadata JSON-LD crawl directives
(head tags) (structured data)
│
▼
dictionary/{locale}/Metadata ← translated title, description, keywords
│
▼
getPathname({ locale, href }) ← correct URLs per locale (as-needed)
Files involved:
| File | SEO
$ claude mcp add next-app-i18n-starter \
-- python -m otcore.mcp_server <graph>