Skip to main content
vibestrap ships fully bilingual (English + Chinese) out of the box. Two systems work together: next-intl for UI strings (under messages/) and a thin naming-convention layer for MDX content (under content/). Once you internalize the conventions there is no extra ceremony — just write twice.

Prerequisites

None. The plumbing is wired up; you only add files.

How locales are routed

Routing is as-needed (src/i18n/routing.ts):
  • English (the default) lives at the root: /docs/quickstart
  • Chinese is prefixed: /zh/docs/quickstart
  • Locale list comes from siteConfig.i18n.locales (['en', 'zh'])
Every locale-prefixed page must call setRequestLocale(locale) at the top — without it, server components fall back to the default locale and your t(...) calls go silent.
const { locale } = await params;
setRequestLocale(locale);

Naming convention for MDX

For each piece of content, ship two files side by side:
content/blog/welcome-to-vibestrap.mdx       # English
content/blog/welcome-to-vibestrap.zh.mdx    # Chinese
The .zh.mdx suffix is the only signal — there is no separate folder, no JSON manifest. The helpers in src/lib/source-helpers.ts strip the suffix to compute the canonical slug.

Locale-aware lookups

Three helpers cover everything:
import { pickByLocale, findBySlug, canonicalSlugs } from '@/lib/source';

// All blog posts, locale-preferred (zh falls back to en if missing)
const posts = pickByLocale(blog.docs, locale);

// Single post by slug
const post = findBySlug(blog.docs, slug, locale);

// Build params for generateStaticParams (en files only)
const slugs = canonicalSlugs(blog.docs);
pickByLocale('zh') returns the .zh.mdx if it exists, otherwise the English fallback so your /zh/ pages never 404 on missing translations.

UI strings — both files, same keys

Translations live in messages/en.json and messages/zh.json. The two files must have identical key shapes; the structural diff is enforced by scripts/check-i18n.mjs.
import { useTranslations } from 'next-intl';

const t = useTranslations('Home.hero');
return <h1>{t('title')}</h1>;
When you add a new key to en.json, add it to zh.json in the same place — even if the Chinese is a placeholder. The validator will yell otherwise.

Adding a new locale

  1. Append to siteConfig.i18n.locales (e.g. ['en', 'zh', 'ja']).
  2. Create messages/ja.json mirroring en.json.
  3. For any MDX you want translated, ship slug.ja.mdx.
  4. Add a new branch in pickByLocale if you want non-en fallbacks.
That’s it — routing picks up the new locale automatically.

Verify it works

node scripts/check-i18n.mjs   # structural parity + literal t() validation
pnpm typecheck                # types are happy
pnpm dev                      # visit /docs and /zh/docs
The validator reports keys that exist in only one file plus any t('foo.bar') call that doesn’t resolve under the namespace declared in the same file.

Common pitfalls

  1. Missing .zh.mdx — the /zh/ page silently falls back to English. Run the dev server and click around in Chinese mode to catch this.
  2. Date drift between variants — when you update an English blog post, also bump the date in the .zh.mdx. Otherwise the index sort order diverges.
  3. Template t() callst(\Foo.$`)` cannot be statically verified. The validator only checks the prefix exists. Smoke-test the page.
  4. Missing setRequestLocale(locale) — locale-prefixed pages without this call render with the default locale’s messages. Easy to miss.
  5. Using next/link directly — always import Link and useRouter from @/i18n/navigation so locale prefixes survive client navigation.

Official docs