DEV Community

M S
M S

Posted on

How I Built a Bilingual (English + Simplified Chinese) Next.js 15 Site Without an i18n Library

Most i18n setups I looked at were built for apps with five or more languages, locale negotiation, and pluralization rules. I was building something much smaller: a couple of marketing-site templates that needed exactly two languages — English and Simplified Chinese — with fully static output.

Pulling in a full i18n library for that felt like a lot of machinery for a small problem. So I tried a lighter approach using nothing but plain TypeScript objects and Next.js App Router route groups. Here's the whole thing.

To be clear up front: this is not "i18n libraries are bad." For a big app with many languages, they earn their weight. This is just the lighter path that fit a two-language static site.

The shape of a translation

Everything starts with one type. Each language file has to implement it, so if I add a new piece of text to one language and forget the other, TypeScript fails the build instead of shipping a half-translated page.

// lib/i18n/dictionary.ts
export type Dictionary = {
  /** Value for <html lang="..."> and hreflang (e.g. "en", "zh-CN"). */
  htmlLang: string;
  /** Short label shown in the language switcher (e.g. "EN", "中文"). */
  label: string;
  meta: { title: string; description: string };
  hero: {
    eyebrow: string;
    titleLine1: string;
    titleLine2: string;
    body: string;
    cta: string;
  };
  // ...features, faq, cta, footer
};
Enter fullscreen mode Exit fullscreen mode

Then each language is just an object of that type:

// lib/i18n/en.ts
import type { Dictionary } from "./dictionary";

export const en: Dictionary = {
  htmlLang: "en",
  label: "EN",
  meta: { title: "Acme Studio — Launch your next big idea", description: "..." },
  hero: {
    eyebrow: "ACME STUDIO",
    titleLine1: "Launch your next big idea",
    titleLine2: "in record time",
    body: "...",
    cta: "Start for free",
  },
};
Enter fullscreen mode Exit fullscreen mode

zh.ts is the same object with Chinese strings and htmlLang: "zh-CN".

One registry, and adding a language is a one-liner

All the languages live in one map. The default locale is served at /, every other locale at /<code>/....

// lib/i18n/index.ts
import { en } from "./en";
import { zh } from "./zh";

export const dictionaries = { en, zh } satisfies Record<string, Dictionary>;

export type Locale = keyof typeof dictionaries;
export const defaultLocale: Locale = "en";
export const locales = Object.keys(dictionaries) as Locale[];

export function isLocale(value: string): value is Locale {
  return value in dictionaries;
}

// localePath("zh", "/") -> "/zh"; the default locale keeps "/".
export function localePath(locale: Locale, path: string) {
  if (locale === defaultLocale) return path;
  return path === "/" ? `/${locale}` : `/${locale}${path}`;
}
Enter fullscreen mode Exit fullscreen mode

To add a third language later, I write one dictionary file and add it to that dictionaries object. Routes, the switcher, and hreflang tags all follow automatically because they iterate over locales.

Routing: a route group for the default, a dynamic segment for the rest

This is the part I like most. Next.js App Router lets you keep the default language at the clean root URL while everything else gets a prefix — with no middleware.

app/
  (en)/            <- route group: served at "/", not "/en"
    layout.tsx
    page.tsx
  [locale]/        <- served at "/zh", "/fr", ...
    layout.tsx
    page.tsx
Enter fullscreen mode Exit fullscreen mode

The (en) folder is a route group — the parentheses mean it does not add a path segment, so English stays at /. The [locale] folder handles every other language.

The dynamic layout locks the routes down to only the languages I actually built, which keeps the site fully static:

// app/[locale]/layout.tsx
export const dynamicParams = false; // anything not pre-built is a 404

export function generateStaticParams() {
  return locales
    .filter((locale) => locale !== defaultLocale)
    .map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;
  if (!isLocale(locale)) notFound();

  return (
    <html lang={dictionaries[locale].htmlLang}>
      <body>{children}</body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Because htmlLang comes straight from the dictionary, <html lang="zh-CN"> is always correct for the Chinese pages and <html lang="en"> for English — which matters for screen readers and search engines.

hreflang for free

Two-language sites still need to tell Google that / and /zh are the same page in different languages. Since I already have the list of locales, I can generate the alternates metadata from it instead of hand-writing tags:

export function pageAlternates(locale: Locale, path: string) {
  const languages: Record<string, string> = {
    "x-default": localePath(defaultLocale, path),
  };
  for (const l of locales) {
    languages[dictionaries[l].htmlLang] = localePath(l, path);
  }
  return { canonical: localePath(locale, path), languages };
}
Enter fullscreen mode Exit fullscreen mode
// app/[locale]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale } = await params;
  return { alternates: pageAlternates(locale, "/") };
}
Enter fullscreen mode Exit fullscreen mode

Next.js turns that into the right <link rel="alternate" hreflang="..."> and canonical tags in the <head>.

What worked, and what I'd change

Worked well:

  • The build catches missing translations. That single Dictionary type has saved me from shipping a page where one language quietly fell back to the other.
  • Zero client-side i18n runtime. Every page is static HTML; there's no library in the bundle.
  • Adding a language is genuinely one dictionary file plus one line.

What I'd change / watch out for:

  • This works because the content is a known, finite set of marketing strings. If you had user-generated content or dozens of pages, hand-maintaining parallel dictionaries would get old fast — that's exactly where a real i18n library starts paying off.
  • No pluralization or number/date formatting here. I didn't need it. If you do, Intl covers a lot before you reach for a library.

See it running

If you want to see this pattern in a working site, I packaged it into a couple of Next.js starter templates — every page ships with English and Simplified Chinese built in:

There's also a free UI kit (no signup) if you just want to poke at the components: https://shinwa-templates.vercel.app

Curious how other people handle two-language sites without a big library — if you've found a cleaner approach, I'd like to hear it in the comments.

Top comments (0)