GameCraftGameCraft

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     ← Chinese

Documentation is fully translated:

http://localhost:3000/en/docs
http://localhost:3000/zh-CN/docs

How It Works

URL Structure

/[lang]/(home)/page.tsx        ← Landing page (all languages)
/[lang]/docs/[[...slug]]/page.tsx  ← Docs (all languages)

Next.js automatically:

  1. Detects [lang] segment
  2. Loads correct translations
  3. 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:

PrioritySourceDescription
1URL prefix/zh-CN/docs → Chinese
2Cookie (webpage_lang)Returning visitors
3Accept-Language headerFirst-time visitors (browser setting)
4Default localeFallback 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:

PrioritySourceDescription
1User DB settingsuser.locale from database
2localStorage (app_locale)Client-side preference
3Browser languagesnavigator.languages detection
4Default localeFallback 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-localematcher for browser language matching

Why Two Different Mechanisms?

AspectWeb PagesSPA Dashboard
SEONeed URL-based i18n for crawlersNo SEO needed
Auth stateMay be anonymousAlways logged in
PersistenceCookie (server-readable)DB + localStorage
RoutingServer-side with redirectsClient-side SPA

Changing Language

Web pages: Use the language switcher in the footer/header, which:

  1. Navigates to the new locale URL (e.g., /zh-CN/docs)
  2. Sets the webpage_lang cookie

Dashboard: Use Settings → Language dropdown, which:

  1. Updates user.locale in database (persists across devices)
  2. Updates localStorage (immediate effect)
  3. 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 docs

Using 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;

Generate types

pnpm typesafe-i18n

This auto-generates type definitions!

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, not authSignIn
  • Keep keys descriptive - home.hero.title, not t1
  • 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-i18n

Missing 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


Resources

On this page