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
};
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",
},
};
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}`;
}
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
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>
);
}
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 };
}
// app/[locale]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale } = await params;
return { alternates: pageAlternates(locale, "/") };
}
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
Dictionarytype 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,
Intlcovers 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:
- SaaS Starter — https://saas-starter-en.vercel.app (Chinese version at
/zh) - Landing Starter — https://landing-starter-en.vercel.app
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)