Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 3 min read

Bilingual static sites in Next.js 16 without an i18n library

Small businesses in Thailand usually need their website in two languages: Thai for local customers, English for everyone else. Most of them also want the cheapest possible hosting. That rules out a server just to pick a

Small businesses in Thailand usually need their website in two languages: Thai for local customers, English for everyone else. Most of them also want the cheapest possible hosting. That rules out a server just to pick a language, so here is the setup I use: a fully static Next.js 16 export with Thai at / and English at /en/, and no i18n library.

The full code is in a free, MIT-licensed yoga studio template, so you can read along: Saeng Lite (live demo).

1. Two root layouts, one for each URL shape

The default language lives at the root, and the other language gets a prefix. With the App Router you can do that with two route groups, each with its own root layout:

src/app/
  (default)/layout.tsx        โ†’ /           (Thai)
  (default)/page.tsx
  (prefixed)/[lang]/layout.tsx โ†’ /en/       (English)
  (prefixed)/[lang]/page.tsx

The prefixed layout only accepts the languages you list, which keeps output: "export" happy:

// src/app/(prefixed)/[lang]/layout.tsx
export const dynamicParams = false;
export function generateStaticParams() {
  return prefixedLocales.map((lang) => ({ lang })); // ["en"]
}

Both layouts render the same RootDocument with a locale prop, so there is no duplicated markup. Two root layouts means two 404s, so experimental.globalNotFound in next.config.ts gives you one 404 page for both.

2. Every string is { th, en }

All copy lives in one typed file, src/content/site.ts:

export type Locale = "th" | "en";
export type L = Record<Locale, string>;

export const site = {
  fullName: { th: "เนเธชเธ‡ เน‚เธขเธ„เธฐเธจเธฒเธฅเธฒ", en: "Saeng Yoga Shala" },
  tagline: { th: "เน‚เธขเธ„เธฐเธจเธฒเธฅเธฒ เน€เธŠเธตเธขเธ‡เนƒเธซเธกเนˆ", en: "Yoga shala ยท Chiang Mai" },
  // โ€ฆ
};

Because each value is a Record<Locale, string>, a missing translation is a TypeScript error at build time instead of a blank on the live site. It also means a non-developer can edit the whole website in one file without touching a component, which is the main thing small clients ask for.

3. SEO per language: canonical, hreflang, x-default

Every page calls one helper, which builds the canonical URL and the alternates for both languages:

export function alternates(path: string) {
  const languages: Record<string, string> = {};
  for (const l of locales) languages[l] = absolute(href(l, path));
  languages["x-default"] = absolute(href(defaultLocale, path));
  return languages;
}
// in pageMetadata():
alternates: { canonical: url, languages: alternates(path) },
openGraph: { locale: t(i18n.ogLocale, locale), /* th_TH or en_US */ },

sitemap.ts loops over the same locale list, so every page appears once per language.

4. Thai fonts: pick loopless

Traditional Thai typefaces have small loops (เธซเธฑเธง) on many letters. Next to a clean Latin sans they look dated and get muddy at small sizes on phones. Use a loopless family instead. next/font makes that a one-liner:

import { Kanit, Noto_Sans_Thai } from "next/font/google";

Here Kanit is used for headings and Noto Sans Thai for body text. IBM Plex Sans Thai and Anuphan are good alternatives.

5. Thai line breaking

Thai has no spaces between words, so the browser guesses where to break a line, and it sometimes splits a word in half. In headings I only allow breaks at the spaces the writer typed: each phrase becomes an inline-block.

const THAI = /[เธ€-เนฟ]/;
export function phrases(text: string) {
  if (!THAI.test(text)) return text;
  return text.split(" ").map((p, i, a) => (
    <Fragment key={i}>
      <span className="inline-block">{p}</span>
      {i < a.length - 1 ? " " : null}
    </Fragment>
  ));
}

Also watch flex and grid children. A long Thai string has a huge min-content width and can push a layout off-screen on mobile. min-width: 0 on the child (Tailwind: min-w-0) fixes it.

6. Static export, host anywhere

// next.config.ts
output: "export",
trailingSlash: true, // /en/ โ†’ /en/index.html, works on every static host

npm run build writes plain HTML to out/. It deploys as-is to Cloudflare Pages, Netlify or Vercel on a free plan.

Try it

I also sell full multi-page versions of these for other industries (hotels, salons, bakeries, law firms and more) at https://softkit-studio.pages.dev. Questions about the i18n setup are welcome in the comments.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.