Skip to content
HOZU0.26.1
Menu
Documentation

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:

typescript
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 missing site.lang or a tag that is not canonical ('zh-TW', not 'zh_tw') is HZ042.
  • <html lang>, the hreflang alternates, og:locale and 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:

typescript
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:

typescript
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.