// TL;DR
On 2026-05-09, three days into the RS TROPHY monorepo rebuild, I shipped two changes together: next-intl Thai/English routing with Thai as the default locale, and the extraction of the shadcn component set into a shared @rs-trophy/ui workspace package. They shipped together because a bilingual app forces you to decide where strings live, and a copy-pasted component library has no good answer. The rule that came out of it: shared components take strings as props and never call a translation hook themselves.
On 2026-05-06 I bootstrapped a Bun workspace monorepo for RUAMSUK PLATING LIMITED PARTNERSHIP — the trophy and medal company my family has run for twenty years — with a storefront, an admin console, an ElysiaJS API, and empty slots for shared UI and type packages. On 2026-05-09, three days later, I filled one of those slots and made the whole thing bilingual in the same pass.
Worth stating plainly, because the dates give it away: my day job at that point was still Jasmine Technology Solution. The CTO title at RS Trophy did not start until 2026-08-01. This was a rebuild I was doing for the company before I formally owned its technology, which is a specific kind of pressure — nothing was allowed to be a research project.
//Thai is the default locale, not the translation
RS Trophy sells custom trophies, medals and award plaques nationwide across Thailand. Almost every customer reads Thai. So the routing config makes th the default and en the second locale — the opposite of the reflex where English is the source language and everything else is a translation layer bolted on later.
That ordering is not cosmetic. It decides which language the message catalogue is authored in, which one gets reviewed by someone who actually speaks it, and which one is allowed to be slightly awkward. If English is the source, Thai copy ends up as a machine-shaped echo of English sentence structure, and it reads like it.
export const routing = defineRouting({
locales: ['th', 'en'],
defaultLocale: 'th'
})
// Every [locale] page must do this, or it silently opts into dynamic rendering.
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale }))
}
export default async function Page({ params }) {
const { locale } = await params
setRequestLocale(locale)
// ...
}The setRequestLocale call is the part that bites. next-intl reads the locale from request context, and reading request context is exactly what makes an App Router segment dynamic. Miss the call on one page and that page quietly drops out of static rendering — no error, no warning, just a route that is now server-rendered on every hit. On a catalogue site whose entire SEO argument rests on fast static pages, that is a real regression that produces no failure signal.
//What Thai actually breaks
Localization tutorials stop at swapping strings. Thai does not stop there, because the script itself violates layout assumptions that Latin typography quietly baked in.
- No spaces between words. Browsers break lines at spaces. A Thai paragraph has none, so a naive layout either refuses to wrap or wraps mid-word. Setting
langcorrectly on the document is what lets the browser use dictionary-based line breaking at all. - Vowels and tone marks stack above and below the baseline. A line-height tuned for Latin clips them or crowds them. Thai needs more leading than the same font size in English — which means line-height cannot be a single global number.
- No plural forms. Thai has one grammatical number, so ICU plural rules collapse to
other. Any string you built by concatenating a count with an English plural suffix has no Thai equivalent and has to be rewritten as a whole message. - Fallback fonts are visible. A Latin-only typeface silently hands Thai glyphs to a system fallback, and the two scripts stop looking like the same brand.
//Why the shared UI package shipped the same day
shadcn/ui is not a dependency. It is a generator that copies component source into your app, which is its best property and its worst one in a monorepo: with a storefront and an admin console, you get two divergent copies of the same button on day one. Extracting them into @rs-trophy/ui was less about reuse than about having exactly one place where a decision lands.
{
"name": "@rs-trophy/ui",
"exports": { "./*": "./src/*.tsx" }
}
// next.config.ts in each consuming app
const config = {
transpilePackages: ['@rs-trophy/ui']
}Two things go wrong here and neither announces itself. First, Tailwind only generates the classes it can find, so the package source has to be inside the content the app scans — otherwise the components render unstyled in one app and fine in the other, depending on which classes happened to exist elsewhere. Second, the shadcn CLI keeps writing into the app it was run in, so the alias config has to be repointed or the next component you add lands in the wrong place and starts the divergence over.
//The rule: shared components do not know their language
The tempting move is to let a shared component call the translation hook itself. It reads cleanly and it is a trap — the component now depends on next-intl, on a specific message namespace existing, and on every consumer wiring the same provider. A UI package that cannot render in a test or a story without a translation context is not a UI package.
// packages/ui — no i18n import anywhere in here
export function EmptyState({ title, action }: { title: string; action: string }) {
return <div><h3>{title}</h3><button>{action}</button></div>
}
// apps/web — the app owns the strings
const t = await getTranslations('catalog')
return <EmptyState title={t('empty.title')} action={t('empty.cta')} />Now the argument against my own case. Extracting a shared package on day four of a codebase is premature by any normal standard. I had two consumers, no third one planned, and no idea yet what the admin console would need that the storefront would not. The cost is real: every component change is now a cross-package edit, and I have already had to un-share two components that turned out to be storefront-specific pretending to be generic.
I would still do it in this order, for one reason that is specific rather than general. The bilingual pass touches every component that renders text. If the components had still been duplicated across two apps, I would have paid for that pass twice, and the second copy would have drifted. Doing i18n first and extraction later would have been the expensive ordering.
// What I'd Do Differently
- Pick the default locale by who actually reads the site, not by what the framework examples assume. Making Thai the source language changed how the copy reads more than any amount of translation review would have.
- Localization is a layout problem before it is a strings problem. Line-height, line-breaking and font coverage all had to change, and none of them were in the ticket I wrote for myself.
- I would still extract the shared package early, but I was too generous about what counted as shared. Twice I have had to pull a component back out into the app it really belonged to — the cheaper default is to duplicate until the second consumer genuinely disagrees with the first.
- A silent opt-out of static rendering is worse than a crash. If I rebuilt this, I would add a build-time assertion that every localized route is still statically generated, instead of trusting myself to remember one function call per page.
FAQ
5Q1 //Should Thai or English be the default locale in a Thai e-commerce site?
Make Thai the default if Thai speakers are the customers. Beyond routing, the default locale is the language your message catalogue is authored and reviewed in — everything else becomes a translation of it. Authoring in English and translating to Thai produces Thai copy with English sentence structure, which native readers notice immediately.
Q2 //Why do my next-intl pages stop being statically rendered?
Because reading the locale from request context makes an App Router segment dynamic. In next-intl you avoid it by calling setRequestLocale at the top of each localized page or layout, alongside a generateStaticParams that enumerates the locales. The failure is silent — pages keep working, they just render per request — so it is worth asserting on at build time.
Q3 //What breaks when you add Thai to an interface designed in English?
Line breaking, vertical rhythm and fonts. Thai has no spaces between words, so wrapping depends on the browser knowing the text is Thai; tone marks and vowels stack above and below the baseline, so Latin-tuned line-height crowds or clips them; and a Latin-only typeface hands Thai to a fallback font that does not match your brand. Thai also has no plural forms, so any count-plus-suffix string has to be rewritten as a complete message.
Q4 //How do you share shadcn/ui components across a monorepo?
Move the generated component source into a workspace package, export the source directly, and add the package to transpilePackages in each consuming app so no separate build step is needed. Two things need attention: your Tailwind content configuration must scan the package source or classes get dropped, and the shadcn CLI aliases must be repointed at the package so newly added components do not land back in an app.
Q5 //Should a shared UI package call translation hooks?
No. A component that calls a translation hook depends on the i18n library, on a specific message namespace, and on every consumer providing the same context — and it can no longer be rendered in a test or a story on its own. Pass display strings in as props and let each app resolve them. The package stays portable and the ownership of copy stays in one place.