How it works
xoong has three layers, and you can stop at any of them: a reactive core and base element, a data layer generated from OpenAPI, and ready-made admin parts on top.
A read, from the markup you write to the rows on the screen. Dashed: an answer, or the store telling the screen. The people and the names of the services are examples. In motion, a dot travels the arrows in numbered order while the line of code that causes the step is lit, and the screen changes with it: loading rows first, then the table. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.
The picture is the whole idea. The few lines on the left are all you write; they make the screen. The client asks your services for the data, keeps each record once, and writes only the DOM nodes that show it. A click goes the same road and ends by refreshing what it touched: see Actions and forms and the Data flow page, which has every step.
Data
xoong sync turns your OpenAPI document into operations.json. A <xo-view get="listPeople"> calls the operation, caches the answer in a shared store (records with the same id are shared between screens) and exposes $loading, $error and the data to the markup inside it. You never write a cache key, an HTTP client or an error handler.
<xo-view get="listPeople">
<p xo-if="$loading">Loading…</p>
<p xo-if="$error" role="alert">{$error.code}: {$error.detail}</p>
<ul>
<li xo-each="items">{name} <small>{email}</small></li>
</ul>
</xo-view>npx xoong sync openapi.jsonActions and forms
Writes are attributes too. A button runs an operation; after it succeeds, every cached query whose path overlaps it is refetched, so lists stay current without extra code. A form with <xo-form op="createPerson"> is drawn from the request schema: nested objects, arrays and unions included. Validation comes from the same schema, with your own rules by path.
A write, from the click to the screen. The client then reads the list again, because the write touched its path. Dashed: an answer, or the client telling the screen. The people and the names of the services are examples. In motion, a dot travels the arrows in numbered order while the code that causes the step is lit; the screen shows the question, then the changed row. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.
A form is read from the schema, not written. Nothing is sent until the values are valid. Dashed: an answer, or the model telling the screen. The names of the services are examples. In motion, a dot travels the arrows in numbered order while the code that causes the step is lit; the screen shows an empty form, the typed values, the error, the corrected value, and the cleared form. A hollow dot rides a dashed arrow. Use the slider, or press a number, to look at one step.
<button xo-click="POST /api/people/{id}/disable" xo-confirm="Disable {name}?" xo-notice="Disabled">Disable</button>
<xo-form op="createPerson" submit="Add person"></xo-form>// Only when HTML is not enough: the same client, typed from api.d.ts
import { getClient } from '@xoong/data';
const created = await getClient().mutate('createPerson', {}, { name: 'Ada', email: 'ada@example.org' });Pages
<xo-router> is a declarative History-API router with nested layouts, guards and a "leave this page?" question for unsaved forms. Public pages stay one HTML file each and can be prerendered with @xoong/ssr.
<xo-router base="/admin">
<template xo-layout="shell">
<nav><a href="/people">People</a></nav>
<main xo-outlet></main>
</template>
<template xo-route="/people/:id" layout="shell" title="Person">
<xo-view get="getPerson" p-id=":id">...</xo-view>
</template>
<template xo-route="*">Not found</template>
</xo-router>Language
@xoong/i18n uses plain JSON catalogs and ICU-style messages. xo-t translates markup, and every built-in string (table, forms, controls) is a catalog key, Vietnamese included.
<h1 xo-t="home.title">Welcome</h1>
<input xo-t="placeholder:search.placeholder" placeholder="Search">import { configureI18n, setLocale } from '@xoong/i18n';
configureI18n({ locale: 'en', load: (locale) => fetch(`/i18n/${locale}.json`).then((r) => r.json()) });
await setLocale('vi');Safety
xoong build rewrites imports to files and removes the need for inline code, so the output runs under default-src 'none'; script-src 'self'; style-src 'self'. This site is built that way, and its tests fail on any CSP violation. Interpolated text is escaped, javascript: URLs become about:blank, and rich text is sanitised on every input.
Packages
Install only what you use. Sizes are gzip budgets that the tests enforce.
| Package | What it is | Budget |
|---|---|---|
@xoong/core | Signals, html templates, XoElement, XoControl | 5.9 KB |
@xoong/data | Typed client, entity store, <xo-view>, live updates | 19.8 KB |
@xoong/forms | Form model and binder: validation, arrays, dirty state | 9.5 KB |
@xoong/ui | xoong.css tokens, table, generated form, dialogs (confirm, alert, prompt), toast, router | 13.1 KB |
@xoong/controls | Tabs, menu, tooltip, combobox, tags, carousel, switch, date, time, slider, stepper, password, copy, image, theme toggle, file, accordion, popover, consent, command, chart | 37 KB |
@xoong/richtext | Sanitised rich text editor | 8.5 KB |
@xoong/i18n | Catalogs, ICU-subset messages, xo-t | 5.7 KB |
@xoong/cli | sync, check, dev, build, render, i18n | tools |
@xoong/test, mcp, lsp, ssr, server | Mock API and screens for Vitest, an MCP server, editor support, prerendering, the dev and build server | tools |