Internationalization (i18n)
Add multi-language support to your ProductReady app with type-safe translations
Internationalization (i18n)
ProductReady comes with built-in i18n support using typesafe-i18n. Add multiple languages to your app with:
- ✅ Type-safe translations - Auto-complete for all translation keys
- ✅ English + Chinese included out of the box
- ✅ Easy to add new languages - Just add translation files
- ✅ Works everywhere - Server components, client components, API routes
Zero runtime overhead: All translations are compile-time checked and tree-shaken!
Quick Start
View Multilingual Content
ProductReady URLs include language prefix:
http://localhost:3000/en ← English
http://localhost:3000/zh-CN ← ChineseDocumentation is fully translated:
http://localhost:3000/en/docs
http://localhost:3000/zh-CN/docsHow It Works
URL Structure
/[lang]/(home)/page.tsx ← Landing page (all languages)
/[lang]/docs/[[...slug]]/page.tsx ← Docs (all languages)Next.js automatically:
- Detects
[lang]segment - Loads correct translations
- Renders localized content
Language Detection Mechanism
ProductReady uses two different language detection strategies depending on the context:
Web Pages (Marketing, Docs, Auth)
For URL-based routes like /, /docs, /pricing, /sign-in, the middleware uses this priority:
| Priority | Source | Description |
|---|---|---|
| 1 | URL prefix | /zh-CN/docs → Chinese |
| 2 | Cookie (webpage_lang) | Returning visitors |
| 3 | Accept-Language header | First-time visitors (browser setting) |
| 4 | Default locale | Fallback to en |
When a user visits a page:
- If URL has language prefix → use that language, set cookie
- If no prefix but cookie exists → redirect to preferred language
- If first-time visitor → detect from browser's Accept-Language header
- Otherwise → use default locale (English)
The cookie persists for 1 year, so returning visitors see their preferred language.
SPA Pages (Dashboard, Agent, Spaces)
For dashboard routes like /dashboard, /agent, /spaces/*, the LocaleProvider uses this priority:
| Priority | Source | Description |
|---|---|---|
| 1 | User DB settings | user.locale from database |
| 2 | localStorage (app_locale) | Client-side preference |
| 3 | Browser languages | navigator.languages detection |
| 4 | Default locale | Fallback to en |
Key differences from Web pages:
- No URL prefix - Dashboard routes don't have
/zh-CN/dashboard - User settings first - Logged-in users' DB preference takes priority
- localStorage persistence - Faster than cookie for SPA navigation
- Client-side detection - Uses
@formatjs/intl-localematcherfor browser language matching
Why Two Different Mechanisms?
| Aspect | Web Pages | SPA Dashboard |
|---|---|---|
| SEO | Need URL-based i18n for crawlers | No SEO needed |
| Auth state | May be anonymous | Always logged in |
| Persistence | Cookie (server-readable) | DB + localStorage |
| Routing | Server-side with redirects | Client-side SPA |
Changing Language
Web pages: Use the language switcher in the footer/header, which:
- Navigates to the new locale URL (e.g.,
/zh-CN/docs) - Sets the
webpage_langcookie
Dashboard: Use Settings → Language dropdown, which:
- Updates
user.localein database (persists across devices) - Updates localStorage (immediate effect)
- Reloads dictionary without page refresh
Project Structure
src/
├── lib/
│ └── i18n/
│ ├── en/ # English translations
│ │ └── index.ts
│ ├── zh-CN/ # Chinese translations
│ │ └── index.ts
│ ├── i18n-types.ts # Generated types (auto)
│ ├── i18n-util.ts # Utilities
│ └── formatters.ts # Custom formatters
├── app/
│ └── [lang]/ # Language-aware routes
└── content/
└── docs/
├── en/ # English docs
└── zh-CN/ # Chinese docsUsing Translations
In Server Components
// src/app/[lang]/(home)/page.tsx
import { loadLocale } from '~/lib/i18n/i18n-util.sync';
import LL from '~/lib/i18n/i18n-types';
export default function HomePage({
params,
}: {
params: { lang: string };
}) {
// Load translations for this language
const { lang } = params;
loadLocale(lang);
const L = LL[lang];
return (
<div>
<h1>{L.home.title()}</h1>
<p>{L.home.description()}</p>
</div>
);
}In Client Components
'use client';
import { useI18nContext } from '~/lib/i18n/i18n-react';
export function WelcomeMessage() {
const { LL } = useI18nContext();
return (
<div>
<h2>{LL.welcome.greeting()}</h2>
<p>{LL.welcome.subtitle()}</p>
</div>
);
}With Parameters
// Translation file
export default {
greeting: 'Hello, {name}!',
items: 'You have {count} items',
} as const;
// Usage
LL.greeting({ name: 'John' }); // "Hello, John!"
LL.items({ count: 5 }); // "You have 5 items"Adding a New Language
Let's add Spanish!
Update config
Edit .typesafe-i18n.json:
{
"baseLocale": "en",
"locales": [
"en",
"zh-CN",
"es" // ← Add this
]
}Create translation file
Create src/lib/i18n/es/index.ts:
import type { Translation } from '../i18n-types';
const es: Translation = {
home: {
title: 'Bienvenido a ProductReady',
description: 'Lanza aplicaciones agénticas en días, no semanas',
getStarted: 'Empezar',
},
nav: {
docs: 'Documentación',
blog: 'Blog',
login: 'Iniciar sesión',
},
auth: {
signUp: 'Registrarse',
signIn: 'Iniciar sesión',
email: 'Correo electrónico',
password: 'Contraseña',
},
};
export default es;Create Spanish docs
Create content/docs/es/ directory:
mkdir -p content/docs/es
cp content/docs/en/index.mdx content/docs/es/index.mdx
# Translate content...Update language switcher
Edit src/components/language-switcher.tsx:
const languages = [
{ code: 'en', name: 'English' },
{ code: 'zh-CN', name: '中文' },
{ code: 'es', name: 'Español' }, // ← Add this
];Done! Spanish is now available at /es/*
Translation Files
Structure
// src/lib/i18n/en/index.ts
import type { BaseTranslation } from '../i18n-types';
const en: BaseTranslation = {
// Grouped by feature/page
home: {
title: 'Welcome to ProductReady',
description: 'Ship faster',
},
auth: {
signIn: 'Sign In',
signUp: 'Sign Up',
forgotPassword: 'Forgot password?',
},
dashboard: {
title: 'Dashboard',
tasks: {
title: 'Tasks',
create: 'Create Task',
delete: 'Delete',
},
},
errors: {
notFound: 'Page not found',
serverError: 'Something went wrong',
},
};
export default en;With Plurals
const en: BaseTranslation = {
items: '{count:number} {count|plural|one item|other items}',
};
// Usage
LL.items({ count: 0 }); // "0 items"
LL.items({ count: 1 }); // "1 item"
LL.items({ count: 5 }); // "5 items"With Dates
const en: BaseTranslation = {
lastUpdated: 'Last updated: {date:Date|datetime}',
};
// Usage
LL.lastUpdated({ date: new Date() });
// "Last updated: Nov 17, 2025, 10:30 AM"Language Switcher
Create a language switcher component:
'use client';
import { useParams, useRouter } from 'next/navigation';
export function LanguageSwitcher() {
const router = useRouter();
const params = useParams();
const currentLang = params.lang as string;
const languages = [
{ code: 'en', name: 'English', flag: '🇺🇸' },
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
];
function switchLanguage(newLang: string) {
const currentPath = window.location.pathname;
const newPath = currentPath.replace(`/${currentLang}`, `/${newLang}`);
router.push(newPath);
}
return (
<select
value={currentLang}
onChange={(e) => switchLanguage(e.target.value)}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.flag} {lang.name}
</option>
))}
</select>
);
}SEO & Metadata
Language-aware metadata
// src/app/[lang]/layout.tsx
export function generateMetadata({
params,
}: {
params: { lang: string };
}) {
const { lang } = params;
loadLocale(lang);
const L = LL[lang];
return {
title: L.seo.title(),
description: L.seo.description(),
alternates: {
languages: {
en: '/en',
'zh-CN': '/zh-CN',
},
},
};
}Hreflang tags
Next.js automatically adds hreflang tags:
<link rel="alternate" hreflang="en" href="https://yourapp.com/en" />
<link rel="alternate" hreflang="zh-CN" href="https://yourapp.com/zh-CN" />Best Practices
✅ Do
- Group by feature - Organize translations logically
- Use namespaces -
auth.signIn, notauthSignIn - Keep keys descriptive -
home.hero.title, nott1 - Provide context - Add comments for translators
- Use plurals - Handle singular/plural correctly
- Format dates/numbers - Use built-in formatters
// ✅ Good
const en: BaseTranslation = {
auth: {
signIn: 'Sign In',
signUp: 'Sign Up',
// Comment: Shown on forgot password link
forgotPassword: 'Forgot password?',
},
};❌ Don't
- Don't hardcode strings - Always use translations
- Don't split sentences - Translate full sentences
- Don't assume word order - Different languages have different grammar
- Don't use abbreviations - They don't translate well
// ❌ Bad
const en = {
s1: 'Click', // ← Don't split
s2: 'here', // ← sentences
btnTxt: 'Submit', // ← Use full names
};
// ✅ Good
const en = {
callToAction: 'Click here to submit',
};RTL Support
For Arabic, Hebrew, etc.:
// src/lib/i18n/ar/index.ts
const ar: Translation = {
// ... translations
};
export default ar;Update layout:
// src/app/[lang]/layout.tsx
export default function Layout({ params }: { params: { lang: string } }) {
const isRTL = ['ar', 'he'].includes(params.lang);
return (
<html lang={params.lang} dir={isRTL ? 'rtl' : 'ltr'}>
<body>{children}</body>
</html>
);
}Dynamic Content
User-generated content
Don't translate user content - only UI:
// ✅ Translate UI
<h2>{LL.tasks.title()}</h2>
// ❌ Don't translate user data
<p>{task.description}</p>Mix translated + dynamic
const en: BaseTranslation = {
taskCount: 'You have {count} {status} tasks',
};
// Usage
LL.taskCount({
count: 5,
status: 'pending', // ← Dynamic, not translated
});Testing
Test translations work:
import { loadLocale } from '~/lib/i18n/i18n-util.sync';
import LL from '~/lib/i18n/i18n-types';
test('English translation exists', () => {
loadLocale('en');
const L = LL.en;
expect(L.home.title()).toBe('Welcome to ProductReady');
});
test('Chinese translation exists', () => {
loadLocale('zh-CN');
const L = LL['zh-CN'];
expect(L.home.title()).toBe('欢迎来到 ProductReady');
});Common Patterns
Loading states
const en: BaseTranslation = {
loading: {
default: 'Loading...',
tasks: 'Loading tasks...',
users: 'Loading users...',
},
};Error messages
const en: BaseTranslation = {
errors: {
required: '{field} is required',
invalid: '{field} is invalid',
notFound: '{resource} not found',
},
};
// Usage
LL.errors.required({ field: 'Email' }); // "Email is required"
LL.errors.notFound({ resource: 'User' }); // "User not found"Form labels
const en: BaseTranslation = {
forms: {
labels: {
email: 'Email address',
password: 'Password',
confirmPassword: 'Confirm password',
},
placeholders: {
email: 'you@example.com',
password: 'At least 8 characters',
},
},
};Troubleshooting
"Translation key not found"
Issue: TypeScript complains about missing key.
Fix: Regenerate types:
pnpm typesafe-i18nMissing translations in new language
Issue: New language shows English fallback.
Fix: Ensure all keys match base locale:
// src/lib/i18n/es/index.ts
import type { Translation } from '../i18n-types';
// This ensures you don't miss any keys!
const es: Translation = {
// Must match all keys in base locale (en)
};Language switcher doesn't work
Issue: Switching language doesn't update content.
Fix: Clear cache and hard reload (Cmd+Shift+R)
Next Steps
- Documentation - Translate docs with MDX
- SEO Guide - Multi-language SEO
- Testing - Test translations
Resources
- typesafe-i18n: github.com/ivanhofer/typesafe-i18n
- Translation Files:
src/lib/i18n/ - Config:
.typesafe-i18n.json