Languages
Serve one app in several languages, with the language in the URL and messages checked per locale.
The URL holds the language
List the languages on the site. site.lang keeps its URLs, and every other locale is prefixed:
project({
site: { url: 'https://example.com', name: 'Notes', lang: 'en', locales: ['en', 'de'] },
// …
})
/posts/a is English and /de/posts/a German; /en/posts/a answers 308 /posts/a. Routes and ui.link stay locale-free: every link and navigate resolves in the page's locale, so a reload, a sign-in or a sign-out keeps the language. There is no cookie, session field or Accept-Language redirect: a new visit to an unprefixed URL shows site.lang.
- A page route that starts with a locale segment is HZ060.
- An empty
site.locales, a missingsite.langor a tag that is not canonical ('zh-TW', not'zh_tw') is HZ042. <html lang>, thehreflangalternates,og:localeand the sitemap entries for every locale are derived.
A language switch links to the same page in another locale: ui.a({ href: ui.alternate('de') }, ['Deutsch']), or locale === 'en' ? ui.alternate('de') : ui.alternate('en') for a toggle.
Messages
Declare a feature's text with ui.messages, the base locale first, and export it from a module the feature lists in declarations:
export const text = ui.messages('en', {
en: { title: 'Notes', saved: '{count} saved', items: '{n, plural, =0 {none} one {# item} other {# items}}' },
de: { title: 'Notizen', saved: '{count} gespeichert', items: '{n, plural, =0 {keine} one {# Eintrag} other {# Einträge}}' },
})
Views and head.render call them: text.title, text.saved({ count: ctx.count }). Every locale needs every key with the same {placeholders} (HZ040). The server lowers messages for the page's locale, so an island receives only that language's strings.
Machines never hold translated text (HZ041 for a message, ui.format or locale in a machine): store a code, ctx.error = 'duplicate', and choose the message in the view. A form's Invalid messages come from the schema in one language, so store a flag there too.
Numbers, dates and lists
ui.format uses Intl in the page's locale:
| Call | Example output (en) |
|---|---|
ui.format.number(price, { style: 'currency', currency: 'EUR' }) |
€12.50 |
ui.format.date(post.published, { dateStyle: 'medium' }) |
Sep 12, 2026 |
ui.format.relative(-3, 'day') |
3 days ago |
ui.format.list(names) |
Ada, Bob, and Eve |
ui.format.plural(n, { one: '# item', other: '# items' }) |
1 item |
ui.format.date takes an ISO string or a timestamp and formats it in the time zone of the machine that renders it. A date without a time ('2026-09-12') is midnight UTC: pass { timeZone: 'UTC' } so every server and browser shows the same day.
Data per language
Resolvers have no locale of their own; it reaches them only through the query input. Every view's render receives locale, and the head functions receive it as an argument:
ui.page(post, {
views: [Post],
head: {
query: getPost,
input: (params, locale) => ({ slug: params.slug, locale }),
render: (item, params, locale) => ({ title: item.title }),
failed: { NotFound: 404 },
},
})
locale is the second argument of head.input and the third of head.render; search follows it in both. In views, pass it into the input: ui.query(listPosts, { locale }, { … }). It is typed string: declare the input field as z.string(), or narrow it with locale as Locale. The sitemap lists one entry per entries item in every locale.
Check it
npx hozu get /de/posts/a renders the German page without a server; --select 'link[hreflang]' shows the alternates. npx hozu docs i18n prints the topic for your agent, and Routing covers the routes themselves.