Features & Build Notes · · 3 min read
Bilingual i18n with Intlayer on Cloudflare Workers (Hono JSX)
How this site serves Indonesian at the root and English under /en with Intlayer: per-page dictionaries, dual routing, hreflang — plus the 'reading locales' Workers crash and its fix.
This site is bilingual: Indonesian at the root (/) and English under /en/*, powered by Intlayer. This doc records the architecture — including one major trap when running Intlayer on Cloudflare Workers that isn’t documented anywhere.
Architecture: one dictionary per page
Every page has a dictionary in src/content/*.content.ts. All copy is declared once with t() nodes carrying both languages:
import { t } from "@intlayer/core/transpiler";
import type { Dictionary } from "intlayer";
export default {
key: "home",
content: {
hero: {
titleA: t({ id: "Sistem e-commerce,", en: "E-commerce systems," }),
titleB: t({ id: "bukan sekadar website.", en: "not websites." }),
},
},
} satisfies Dictionary;
Components receive locale and resolve the dictionary per request:
export const Home = ({ site, posts, locale }: Props) => {
const d = dict(homeContent, locale); // every t() becomes a plain string
return <Base site={site} locale={locale} title={d.meta.title} path="/">…</Base>;
};
Routing: default unprefixed, secondary under /en
export const href = (l: Locale, path: string): string =>
l === "id" ? path : path === "/" ? "/en" : "/en" + path;
// dual registration — one handler, two URLs:
const page = (path: string, render: (c: Ctx, locale: Locale) => Response) => {
publicRoutes.get(path, (c) => render(c, "id"));
publicRoutes.get(href("en", path), (c) => render(c, "en"));
};
Every mirrored page emits reciprocal hreflang links (x-default pointing at the Indonesian version) — a hard requirement for Google to honor them.
⚠️ The Cloudflare Workers trap
Importing from the intlayer barrel crashes at module load on Workers:
Uncaught TypeError: Cannot read properties of undefined (reading 'locales')
The cause: the intlayer barrel reads its build-time configuration (@intlayer/config/built) at module scope — that configuration is injected by the Next/Vite bundler plugins, which don’t exist in the Wrangler pipeline. The fix has three layers:
// 1. Import runtime code from @intlayer/core subpaths (pure, no config):
import { getDictionary, translationPlugin } from "@intlayer/core/interpreter";
import { t } from "@intlayer/core/transpiler";
// 2. Supply the translation plugin explicitly — the default set reads the missing config:
export const dict = <T extends Dictionary>(d: T, l: Locale) =>
getDictionary(d, l, [translationPlugin(l, "id")]);
// 3. wrangler.jsonc — intlayer touches the global `process`:
{ "compatibility_flags": ["nodejs_compat"] }
Type-only imports (import type { Dictionary } from "intlayer") remain safe — they’re erased at compile time.
The result
One handler per page, two languages, zero extra JavaScript in the browser, and copy that never forks into two files. Every string lives side by side (id and en adjacent), so translations can’t fall behind.
Want something like this built for your business?