Going Multilingual: English and Chinese Content with Language Switching

Jun 12
·

Series Recap

This is Part 8 — the final installment — of my series on building a personal website with Next.js. If you've followed along from Part 1, you've seen the site grow from zero to a fully-featured blog with posts, videos, tweets, and five different themes.

Today I'm tackling Phase 7: multilingual support. I wanted the site to serve both English and Chinese readers, with a smooth language-switching experience. Let's get into it.


1. Content Schema + File Rename

Why a Computed Language Field?

Before I could build any language-switching UI, I needed the content layer to understand which language each post was in. My content lives in .mdx files, and I decided on a naming convention: post-name.en.mdx for English and post-name.zh.mdx for Chinese.

The beauty of this approach is that the language lives in the filename itself — no frontmatter duplication, no ambiguity. I just needed to teach my content layer to extract it.

Updating the Document Schema

In contentlayer.config.ts, I added a computed lang field to the Post document type:

// contentlayer.config.ts
const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: "posts/*.{en,zh}.mdx",
  contentType: "mdx",
  fields: {
    // ... existing fields (title, description, publishedAt, etc.)
  },
  computedFields: {
    slug: {
      type: "string",
      resolve: (doc) => {
        // Strip the language suffix from the filename
        // "my-post.en.mdx" → "my-post"
        return doc._raw.sourceFileName.replace(/\.(en|zh)\.mdx$/, "")
      },
    },
    lang: {
      type: "string",
      resolve: (doc) => {
        const match = doc._raw.sourceFileName.match(/\.(en|zh)\.mdx$/)
        return match ? match[1] : "en"
      },
    },
    // ... other computed fields
  },
}))

The File Rename Problem

This change had a side effect — the slug computation now strips the language suffix. Previously my slugs looked like my-post.en, which was wrong. After the fix, both my-post.en.mdx and my-post.zh.mdx produce the same slug: my-post.

This is exactly what I want. The slug is language-agnostic; the lang field tells me which version I'm looking at.

The filePathPattern Change

The glob pattern changed from "posts/*.mdx" to "posts/*.{en,zh}.mdx". This is important — it tells Contentlayer to only pick up files that have a language suffix. If someone drops a random.mdx file in the posts folder without a language suffix, it won't be processed. This keeps things clean and forces a deliberate choice about language for every post.


2. Language Hook + Preference

The Core Problem

I needed a way to:

  1. Track the user's current language preference
  2. Persist it across page navigations
  3. Persist it across browser sessions
  4. Avoid hydration mismatches (SSR renders English, client might prefer Chinese)

Building the LangProvider

I created a React Context provider that handles all of this:

// lib/useLang.tsx
"use client"
 
import { createContext, useContext, useState, useEffect, useCallback } from "react"
 
type Lang = "en" | "zh"
 
interface LangContextValue {
  lang: Lang
  setLang: (lang: Lang) => void
  mounted: boolean
}
 
const LangContext = createContext<LangContextValue>({
  lang: "en",
  setLang: () => {},
  mounted: false,
})
 
export function LangProvider({ children }: { children: React.ReactNode }) {
  const [lang, setLangState] = useState<Lang>("en")
  const [mounted, setMounted] = useState(false)
 
  useEffect(() => {
    // Read preference from localStorage after mount
    const stored = localStorage.getItem("lang")
    if (stored === "en" || stored === "zh") {
      setLangState(stored)
    }
    setMounted(true)
  }, [])
 
  const setLang = useCallback((newLang: Lang) => {
    setLangState(newLang)
    localStorage.setItem("lang", newLang)
  }, [])
 
  return <LangContext.Provider value={{ lang, setLang, mounted }}>{children}</LangContext.Provider>
}
 
export function useLang() {
  return useContext(LangContext)
}

Why the mounted State?

This is a subtle but critical detail. During SSR and the initial client render, the component doesn't know what's in localStorage. If I tried to read it during render, I'd get a mismatch between server HTML (which defaults to "en") and client HTML (which might read "zh" from storage).

The mounted flag solves this. Components can check mounted before rendering language-specific content:

const { lang, mounted } = useLang()
 
if (!mounted) {
  // Render a neutral state or the default language
  return <DefaultContent />
}
 
// Now we can safely render based on the persisted preference
return <Content lang={lang} />

Wiring It Up

The provider wraps the app in the root layout:

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <LangProvider>{children}</LangProvider>
      </body>
    </html>
  )
}

Handling Browser Language Detection

I considered auto-detecting the browser's language via navigator.language, but decided against it. My reasoning:

  • Most of my Chinese readers are likely bilingual and can find the toggle
  • Auto-detection can be surprising and hard to override
  • Keeping it simple: English by default, explicit switch to Chinese

If I change my mind later, it's a one-line addition inside the useEffect.


3. Post Page Multilingual

The Architecture Decision

Here's the key insight: each post slug maps to two files (one per language), but I only want one route: /posts/my-post. The language selection happens at render time, not at routing time.

This means getStaticPaths should generate one path per slug, and getStaticProps should load both language versions.

getStaticPaths

// pages/posts/[slug].tsx
export async function getStaticPaths() {
  const posts = allPosts
 
  // Get unique slugs (since en and zh versions share the same slug)
  const slugs = [...new Set(posts.map((post) => post.slug))]
 
  return {
    paths: slugs.map((slug) => ({
      params: { slug },
    })),
    fallback: false,
  }
}

The Set deduplication is the critical piece. Without it, I'd generate duplicate paths for posts that exist in both languages.

getStaticProps

export async function getStaticProps({ params }) {
  const { slug } = params
 
  // Find all posts with this slug (could be en, zh, or both)
  const matchingPosts = allPosts.filter((post) => post.slug === slug)
 
  if (matchingPosts.length === 0) {
    return { notFound: true }
  }
 
  // Build a language → post map
  const posts: Record<string, (typeof allPosts)[0]> = {}
  for (const post of matchingPosts) {
    posts[post.lang] = post
  }
 
  return {
    props: {
      posts,
      slug,
    },
  }
}

Language Selection with Fallback

The component receives both versions and picks the right one:

export default function PostPage({ posts, slug }) {
  const { lang } = useLang()
 
  // Fallback chain: requested lang → English → first available
  const post = posts[lang] || posts["en"] || Object.values(posts)[0]
 
  if (!post) return notFound()
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div className="prose">
        <MDXContent code={post.body.code} />
      </div>
    </article>
  )
}

Why This Fallback Chain?

The fallback is defensive but intentional:

  • Primary: Use the user's preferred language if available
  • Secondary: Fall back to English (my primary language and the one with the most complete content)
  • Tertiary: Use whatever version exists (covers edge cases)

This means if I only have a Chinese version of a post, an English reader will still see it (in Chinese). That's better than a 404. And if I only have an English version, Chinese readers will see the English text — which is also reasonable since I write technical content and my Chinese readers are likely comfortable with English technical writing.


4. Listing Page Multilingual

Translating UI Strings

The post listing page has more than just post content — it has UI strings like "Read more", date formatting, and section headings. These need translation too.

I created a simple translations utility:

// lib/translations.ts
const translations = {
  en: {
    "posts.title": "Blog Posts",
    "posts.subtitle": "Thoughts on web development, design, and more",
    "posts.readMore": "Read more",
    "posts.publishedOn": "Published on",
    "posts.noPosts": "No posts yet.",
    "projects.title": "Projects",
    "videos.title": "Videos",
  },
  zh: {
    "posts.title": "博客文章",
    "posts.subtitle": "关于 Web 开发、设计等的思考",
    "posts.readMore": "阅读全文",
    "posts.publishedOn": "发布于",
    "posts.noPosts": "暂无文章。",
    "projects.title": "项目",
    "videos.title": "视频",
  },
} as const
 
type Lang = keyof typeof translations
type Key = keyof (typeof translations)["en"]
 
export function t(lang: Lang, key: Key): string {
  return translations[lang]?.[key] ?? translations["en"][key] ?? key
}

I chose a flat key structure over nesting. It's simpler to type, simpler to look up, and easier to search for in code. The t() function always falls back to English if a translation is missing.

Translating Post Titles

This is where it gets interesting. Post titles aren't in the translations file — they come from the content itself. So on the listing page, I need to show the right title based on the current language.

// components/PostPreview.tsx
function PostPreview({ slug, posts }: PostPreviewProps) {
  const { lang } = useLang()
 
  // Get the post in the current language, fallback to English
  const post = posts[lang] || posts["en"] || Object.values(posts)[0]
 
  if (!post) return null
 
  return (
    <article className="post-preview">
      <h3>
        <Link href={`/posts/${slug}`}>{post.title}</Link>
      </h3>
      <time>{formatDate(post.publishedAt, lang)}</time>
      <p>{post.description}</p>
      <Link href={`/posts/${slug}`}>{t(lang, "posts.readMore")}</Link>
    </article>
  )
}

Building the Posts Map

On the listing page, I need to group posts by slug and pass both versions to each preview:

// pages/index.tsx or pages/posts/index.tsx
export async function getStaticProps() {
  // Group posts by slug
  const postsBySlug: Record<string, Record<string, Post>> = {}
 
  for (const post of allPosts) {
    if (!postsBySlug[post.slug]) {
      postsBySlug[post.slug] = {}
    }
    postsBySlug[post.slug][post.lang] = post
  }
 
  // Convert to array sorted by most recent published date
  const postGroups = Object.entries(postsBySlug)
    .map(([slug, posts]) => ({
      slug,
      posts,
      // Use the most recent publish date for sorting
      publishedAt: Math.max(...Object.values(posts).map((p) => new Date(p.publishedAt).getTime())),
    }))
    .sort((a, b) => b.publishedAt - a.publishedAt)
 
  return { props: { postGroups } }
}

The LangSwitch Component

Users need a way to toggle the language. I built a simple toggle button:

// components/LangSwitch.tsx
"use client"
 
import { useLang } from "@/lib/useLang"
 
export function LangSwitch() {
  const { lang, setLang, mounted } = useLang()
 
  // Don't render until mounted to avoid hydration mismatch
  if (!mounted) {
    return <div className="lang-switch" />
  }
 
  return (
    <div className="lang-switch">
      <button
        onClick={() => setLang("en")}
        className={lang === "en" ? "active" : ""}
        aria-label="Switch to English"
      >
        EN
      </button>
      <span className="separator">|</span>
      <button
        onClick={() => setLang("zh")}
        className={lang === "zh" ? "active" : ""}
        aria-label="切换到中文"
      >
        中文
      </button>
    </div>
  )
}

I placed this in the site header so it's accessible from every page. The active state styling gives clear visual feedback about which language is currently selected.

Why a Toggle Instead of a Dropdown?

For exactly two languages, a toggle (or segmented control) is the right UI pattern. A dropdown would add an unnecessary click. If I ever add a third language, I'll switch to a dropdown — but for now, simplicity wins.


5. Seed Chinese Content

The Translation Approach

I decided early on that my translation strategy would be: translate the prose, keep the code in English. This is the standard approach for technical blogs. Code is a universal language, and translating variable names or function calls would make examples harder to follow.

The .zh.mdx Convention

Each Chinese post follows the same structure as its English counterpart, with frontmatter in Chinese and body content translated:

---
title: "构建我的个人网站:第一阶段"
description: "从零开始搭建一个使用 Next.js、Contentlayer 和 Tailwind CSS 的个人博客。"
publishedAt: "2026-05-15"
status: "published"
categories:
  - title: "Next.js"
    slug: "next"
series:
  order: 1
  title: "我如何用 Next.js 构建个人网站"
---
 
## 为什么要自己建站?
 
市面上有很多博客平台——Medium、WordPress、Ghost——但我选择从零开始构建。
 
原因很简单:我想要完全的控制权。
 
<!-- more -->
 
## 技术栈选择
 
我选择了以下技术栈:
 
```bash
npm install next contentlayer tailwindcss
```

Next.js

Next.js 提供了出色的开发体验和生产级性能。文件系统路由、API 路由、 图片优化——都是开箱即用的。

Notice how the code blocks stay in English. The npm install command doesn't need translation. The explanations around it do.

Content Quality Considerations

Machine translation was tempting, but I opted for manual translation for several posts. The reasons:

  • Technical nuance matters — a mistranslated concept is worse than untranslated
  • My writing style in Chinese is different from English — direct translation sounds stiff
  • Some idioms and cultural references don't translate well

For the initial launch, I translated my most popular posts manually and left the rest as English-only. The fallback chain I built means Chinese readers still see content — just in English.

End-to-End Language Toggle

Let me walk through the complete flow:

  1. User visits /posts/my-post — the page loads with both English and Chinese versions of the post
  2. The useLang hook reads the stored preference (defaults to "en")
  3. The post page renders the English version
  4. User clicks "中文" in the header's LangSwitch
  5. setLang("zh") updates the context and persists to localStorage
  6. The post page re-renders — now posts["zh"] is selected
  7. User navigates to the listing page — titles and UI strings are now in Chinese
  8. User closes the browser and returns laterlocalStorage restores "zh"

No page reloads. No route changes. Just instant language switching.


Putting It All Together

The File Structure

Here's what the posts directory looks like with bilingual content:

posts/
building-my-site-phase-1.en.mdx
building-my-site-phase-1.zh.mdx
building-my-site-phase-2.en.mdx
building-my-site-phase-2.zh.mdx
nextjs-tips.en.mdx
nextjs-tips.zh.mdx
random-thoughts.en.mdx
design-system-update.en.mdx

The system gracefully handles posts that only exist in one language. The fallback chain ensures readers always see something.

Styling the Language Switch

A few CSS details that made the switch feel polished:

.lang-switch {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.875rem;
}
 
.lang-switch button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 0.25rem 0.5rem;
  color: var(--text-secondary);
  transition: color 0.2s;
}
 
.lang-switch button.active {
  color: var(--text-primary);
  font-weight: 600;
}
 
.lang-switch .separator {
  color: var(--text-tertiary);
}

The active button gets bolder text and a primary color. The inactive button is muted but still clickable. Simple and effective.


Lessons Learned

Keep It Simple

I initially considered more complex approaches: subpath routing (/en/posts/... and /zh/posts/...), separate builds per language, even a headless CMS with built-in i18n. All of these would have been overkill for a personal blog with two languages.

The filename-based convention (*.en.mdx / *.zh.mdx) with client-side language switching turned out to be the simplest solution that actually works well.

Content is the Hard Part

The infrastructure for multilingual support took about a day to build. Translating the content? That's an ongoing effort. If you're thinking about adding a second language to your site, budget your time accordingly. The code is the easy part.

Hydration Matters

The mounted state pattern showed up multiple times in this phase. Any time you read from localStorage or window in a Next.js app, you need to handle the SSR/client mismatch. Getting this wrong leads to flickering UI or React hydration warnings.


Wrapping Up the Series

And that's a wrap — Phase 7 is complete, and so is this series.

Let me take a moment to look back at the full journey:

Phase 1: Set up the project with Next.js, Contentlayer, and Tailwind CSS. Got the first "Hello World" post rendering.

Phase 2: Built out the blog infrastructure — post pages, listing pages, MDX components, and code syntax highlighting.

Phase 3: Added video and tweet embeds. Started making the content more interactive.

Phase 4: Implemented five themes with a theme toggle. Made the site feel personal and polished.

Phase 5: Integrated Lucide icons, improved the favicon setup, and refined typography.

Phase 6: Added a Table of Contents, reading time estimates, and active heading tracking.

Phase 7: Went multilingual with English and Chinese support.

From an empty directory to a fully-featured personal website with posts, videos, tweets, 5 themes, and bilingual support — all built incrementally, one task at a time.

What's Next?

This series is done, but the site isn't. There's always more to build:

  • RSS feed generation
  • Full-text search
  • Comment system
  • Analytics dashboard
  • More translated content

But the foundation is solid. Every feature in this series was built on top of the previous ones, and the architecture supports extension without rewrites.

Your Turn

If you followed along, you now have a solid foundation. Make it yours. Change the themes. Add your own content categories. Translate into languages I haven't covered. The beauty of building your own site is that it reflects you.

Thanks for reading this series. Happy building. 🚀