How do I…?
One screen per answer. If you have not done the tour, start there instead.
…show a number?
Wrap it in to_string:
You have {{ to_string(unread) }} messages.
Slots put text in the page, so anything that is not already text gets converted. Money too:
{{ to_string(total) }}.
…show a price?
:props: total: Decimal<2>
:!props:
Total: {{ to_string(total) }}
Decimal<2> is exactly two decimal places, always. See chapter 5 for what
happens when you multiply one.
…make a button do something?
:button: on:click=count + 1
increment
:!button:
The part after = is the new value, not code that changes something. on:click=0 sets it to
zero.
…read what someone typed?
:input: on:input=name
:!input:
…submit a form?
:form: on:submit=draft
:input: on:input=draft
:!input:
:button: on:click=draft
save
:!button:
:!form:
…show a list?
:for: line in lines key line.id
:li:
{{ line.label }}
:!li:
:!for:
Always give key something that identifies the item. See
chapter 4.
…hide something until it is ready?
:if: ready
:p:
Everything is set.
:!p:
:!if:
There is no else. Write a second :if: for the other case.
…style a component?
Two sections, and you have to say which:
===style.local
.card { border: 1px solid #ddd; padding: 1rem; }
===
===style.global
body { font-family: system-ui; }
===
local is scoped to this component: .card becomes .card[data-s-page], and star stamps that
attribute on the elements it emits. A component you call gets its own marker, so your rules stop
at its edge.
global is exactly what you wrote.
Neither spelling is shorter than the other, on purpose. There is no default, so nothing leaks because somebody forgot a word.
Generating writes Page.css beside Page.sbmx, with an @import for each component the page
uses. One <link> loads the tree.
…put a class on an element?
Put it in the head, before any on::
:div: class=card
:!div:
:span: class="tag muted"
draft
:!span:
A value with spaces is quoted. A bare name is a boolean attribute — :input: disabled.
…build a link from my data?
Interpolate the value:
:a: href=/posts/{{ to_string(post.id) }}
read more
:!a:
The expression inside {{ }} is checked like everything else, so a typo is a compile error rather
than a link to a page that does not exist.
…use more than one piece of state?
Write a ===bx section. Your state becomes a record, your handlers become messages, and an
update function you can read decides what each one does:
===bx
class Item { id: Int, name: String }
class Model { count: Int, items: [Item] }
enum Msg { Increment, Reset }
pure function update(msg: Msg, m: Model) -> Model {
match msg {
Increment => { return Model { count: m.count + 1, items: m.items }; }
Reset => { return Model { count: 0, items: m.items }; }
}
}
===
:props: model: Model
:!props:
:button: on:click=Msg.Increment
more
:!button:
…put a button on each row?
Key the loop, and let the handler use key:
:for: todo in model.todos key to_string(todo.id)
:li:
:button: on:click=Msg.Toggle(string_to_int(key, 0))
{{ todo.label }}
:!button:
:!li:
:!for:
key is the row’s identity, as text. A DOM attribute is text, so it arrives as a String and
you convert — string_to_int(key, 0) for a number.
What you cannot do is name the loop variable in the handler:
:for: todo in model.todos key to_string(todo.id)
:button: on:click=Msg.Toggle(todo.id)
{{ todo.label }}
:!button:
:!for:
STAR-E007: this handler names `todo`, which does not exist where handlers run —
a handler is a function of (handler, key, state), and `todo` was bound while
drawing the page. Use `key`
todo existed while the page was being drawn. By the time somebody clicks, it is gone — so the row
is identified by its key and update finds it in the state it is given.
…split a screen into several components?
Put the component in its own file, import it, and call it as a block.
Badge.sbmx:
:props: amount: Int, tone: String
:!props:
:span: class=badge
{{ tone }}: {{ to_string(amount) }}
:!span:
Page.sbmx:
===bx
use "./Badge.sbmx";
class Model { unread: Int }
enum Msg { Clear }
pure function update(msg: Msg, m: Model) -> Model {
match msg {
Clear => { return Model { unread: 0 }; }
}
}
===
:props: model: Model
:!props:
:Badge: amount={{ model.unread }} tone=unread
:!Badge:
Generating Page.sbmx generates Badge.sbmx too, beside its own source. One command.
A prop you forget is named, along with what the component wants:
STAR-E017: `Badge` needs `tone`, and this call does not give it.
Its props are `value: Int, tone: String`
And the order you write them in does not matter — arguments are passed in the order the component declares them, so two props of the same type cannot swap by accident.
…use a component somebody else published?
Two kinds of thing can come from a package, and they have different answers.
A Burxt library works today, and its author need not have heard of star. Anyone can publish
ordinary Burxt — money rules, validation, a domain type. Declare it and use it from your ===bx
section:
name my-app
version 0.1.0
dependency star https://github.com/andrecorugda/star-burxt v0.2.0
dependency bmx https://github.com/andrecorugda/bmx burxt-0.12.1
dependency mylib https://github.com/someone/mylib v1.0.0
Then use "mylib/money.bx"; alongside your other imports, and call what it exports. star passes that
line through to the generated component untouched, so the compiler resolves it the way it resolves any
import — there is no star-specific step and nothing to register.
A component library is the same shape, one level up. The author writes ordinary components and publishes the repository:
:props: label: String
:!props:
:span: class=card
{{ label }}
:!span:
You then write use "mylib/Card.sbmx"; and call :Card: label={{ model.title }} exactly as if the
file were yours. Its props are checked at your call site — a missing one is the same STAR-E017 you
get for a local component, naming the component and what it declares.
What star does with it is worth knowing, because you will see the files. A packaged component is
generated into .star/mylib/Card.bx in your tree, never inside the fetched package — a package
directory is a cache keyed by the dependency’s url and tag, so anything written there is thrown away
the day you bump the version. Add .star/ to your .gitignore; it is build output, like the .bx
beside a local .sbmx.
This one needs a compiler that lists
burxt wherein its usage. star finds the package by asking that command, because where a dependency sits on disk is derived from its source and is not something star may guess. Runburxtwith no arguments to see whether yours has it — no version number here, because it is not in a release yet and a number written before the tag exists is a number that will be wrong.Without it, a package-qualified import is read as a path and refused, and star says which of the two things went wrong rather than making you guess: “names a package, and this
burxtcannot resolve one — its usage does not listburxt where” for an old compiler, and the compiler’s own “nosuchis not a dependency ofmy-app” when the name is simply undeclared. Those need opposite fixes. The Burxt-library half above has no such requirement.
…see what my document turned into?
./star-generate page.sbmx page
It prints the component. Reading it is encouraged — there is nothing in there you did not write.
…fetch something?
Two functions. One says what to go and do, the other receives whatever comes back:
===bx
use "std/string.bx";
class Model { items: Int, status: String }
enum Msg { Refresh, Other }
pure function update(msg: Msg, m: Model) -> Model {
match msg {
Refresh => { return Model { items: m.items, status: "loading" }; }
Other => { return m; }
}
}
pure function commands(msg: Msg, m: Model) -> [StarCmd] allocates {
let mutable out: [StarCmd] = [];
match msg {
Refresh => { let a: Int = push(out, StarCmd.Fetch(7, "/api/feed")); }
Other => { }
}
return out;
}
pure function arrived(tag: Int, value: String, m: Model) -> Model {
if tag == 7 { return Model { items: string_to_int(value, 0), status: "ready" }; }
return m;
}
===
The 7 is yours. You pick the tags, so nothing has to know the handler numbers star assigns —
and they move when you add a button.
There is no await, and you do not need one: the browser is already the event loop. An update says
what the next state is, commands says what to go and do about it, and the reply is an ordinary
event. A failed fetch is still an answer, delivered with an empty body, so a page cannot get
stuck in loading with nothing ever arriving.
You can also Send a body, Focus an element, Store and Load across reloads, Go to a path,
and Cancel anything in flight.
…run something on a timer, or watch for a key?
Say what you want to be listening to, as a function of your state:
===bx
class Model { polling: Bool }
pure function watch(m: Model) -> [StarWatch] allocates {
let mutable out: [StarWatch] = [];
if m.polling { let a: Int = push(out, StarWatch.Every(7, 5000)); }
let b: Int = push(out, StarWatch.Key(9, "Escape"));
return out;
}
===
The driver compares this with what is already running and starts or stops the difference. So
stopping a timer is a state change — set polling to false and it stops.
There is no onMounted, no cleanup function to forget, and no way to leak a listener: what is
running is whatever your current state asks for.
Timers, keys and websockets all arrive at arrived, under your tag.
Polling — after
A tick that should re-fetch needs after, and a tick must not be pointed at a fetch’s tag.
===bx
class Model { polling: Bool }
pure function watch(m: Model) -> [StarWatch] allocates {
let mutable out: [StarWatch] = [];
if m.polling { let a: Int = push(out, StarWatch.Every(8, 5000)); } // 8 is the POLL
return out;
}
// What to do after something arrived. `commands` takes a `Msg`, and a reply is not one.
pure function after(tag: Int, m: Model) -> [StarCmd] allocates {
let mutable out: [StarCmd] = [];
if tag == 8 { let a: Int = push(out, StarCmd.Fetch(7, "/api/feed")); } // 7 is the REPLY
return out;
}
===
This is here because the example on this site was wrong for as long as the page existed. It pointed
its timer at the fetch’s tag, so every tick arrived at arrived with an empty body — and
string_to_int("", 0) set the items to zero. Measured: 42 before a tick, 0 after. A live feed that
blanked itself every five seconds.
Two rules come out of it. A tick carries no value, so anything that parses value as a body will
destroy what it had — give the poll its own tag and leave the items alone. And after is the only way
a subscription can ask for anything, because commands is keyed on a Msg and nothing that arrives
has one.
…render on the server?
Give your component a load, and its signature is what makes it server-only:
===bx
class Model { user: String, unread: Int }
function load(request: String) -> Model touches network, files {
// …reach a database, read a file, call an API…
return Model { user: request, unread: 0 };
}
===
Generating emits a page_serve(request) -> String that returns the whole page with the state
embedded in it, so the browser picks up the same value rather than fetching it again:
<div id="root">…your rendered page…</div>
<script type="application/json" id="star-state">{"user":"ada","unread":3}</script>
A view cannot fetch, and that is checked rather than promised. A view is pure, and a pure
function may not call anything impure — so this does not compile:
error: `pure function view` may not call `db_lookup`, which crosses into C:
a pure function's result must depend only on its arguments.
That is the single largest bug class in server-rendered React, refused by the compiler.
And server code does not reach the browser. Not by a bundler rule — the client’s entry points
never call load, so the linker drops it, its string data and the network import it made. Measured:
a password inside load is present in the wasm object file and absent from the linked module. It is
checked on every push, with a control that proves the check can find things.
…use Tailwind, Bootstrap or Ant Design?
You just use them. star never reads a class name — class= is an attribute, its value is a string,
and nothing in the framework inspects it. There is no plugin, no config, and nothing to integrate:
:button: class="px-4 py-2 rounded-lg bg-indigo-600 text-white" child=Save on:click=Msg.Save :!button:
A head is one line, so a long list of utilities is a long line. That is the honest cost of the utility style and it is the same in JSX.
One rule, and it is the one that bites: more than one class means quotes.
Right:
:div: class="card shadow-sm p-4" child=x :!div:
Refused:
:div: class=card shadow-sm p-4 child=x :!div:
STAR-E026
shadow-smhas no value, so it stands for a boolean attribute
An unquoted value ends at the first space, so the unquoted form is one class plus two boolean attributes. That used to happen silently and the extra classes were simply lost; it is refused by name now, which matters most for utility CSS, where four classes on an element is ordinary.
Whether the framework’s stylesheet arrives from a CDN, a build step or your own file is your page’s
business — the same as it would be in any other project. examples/theme.css is what this repository’s
own examples use, and it is nothing more than a stylesheet: tokens, dark mode, and a few component
classes. Swap it for Tailwind’s output and every component keeps working, because the split between what
makes this component itself (===style.local, scoped by star) and what the page decides (type, colour,
spacing) is the same split those frameworks already make.
…use star as the front end of Laravel, Rails or Django?
Two things, and neither needs your backend to know what star is.
1. Render the first paint on the server. --fragment builds a native binary: state JSON in on
stdin, that component’s HTML out on stdout. Nothing else — no doctype, no head, no script tag,
because your layout already has all three.
star-generate Poster.sbmx poster --fragment > render.bx
burxt build render.bx -o bin/poster-render
echo '{"status":"idle"}' | bin/poster-render
Any backend that can run a subprocess can call it. In Laravel:
$state = ['status' => 'idle'];
$html = Process::input(json_encode($state))->run(base_path('bin/poster-render'))->output();
return view('page', ['state' => $state, 'rendered' => $html]);
<div id="root">{!! $rendered !!}</div>
<script type="application/json" id="star-state">@json($state)</script>
One value, used twice — the server renders from it and the page hands the same text to the browser. That is what makes a hydration mismatch impossible rather than unlikely, and it is checked on every push: the native binary and the wasm module must produce the same bytes from the same state.
Without this the page is blank until the wasm arrives — 54 KB of module plus 28 KB of driver before the first pixel. That gap is the flash, and it is not reactivity: a plain React or Vue SPA has exactly the same one. Next, Nuxt and Inertia render first and hydrate after; Alpine avoids it by never being the thing that produces the HTML.
2. Put the CSRF token in the page and stop thinking about it. A StarCmd.Send goes out as
application/json, asks for JSON back, and carries whatever token the page is holding:
| where you put it | what star sends | who does this |
|---|---|---|
<meta name="csrf-token"> |
X-CSRF-TOKEN |
Laravel (web), Rails |
cookie XSRF-TOKEN |
X-XSRF-TOKEN |
Laravel Sanctum, Spring |
cookie csrftoken |
X-CSRFToken |
Django |
Anything else — ASP.NET’s RequestVerificationToken, a bearer token, a tenant id — goes to mount:
await mount({ wasm, root, component: 'poster', initial, reconcile,
headers: { 'Authorization': 'Bearer ' + token },
credentials: 'include' }); // only when the API is on another origin
The token never reaches your component, and that is deliberate. It belongs to the session, not to your state — a component that received one would carry a field about the transport, and then every component would. The page is already where framework knowledge lives.
…run a real app in a browser?
Give your component a way to carry its state as text, and the driver does the rest:
===bx
use "std/json.bx";
class Model { count: Int }
pure function to_text(m: Model) -> String allocates {
let mutable f: [Field] = [];
let a: Int = push(f, json_field("count", json_int(m.count)));
return json_render(json_object(f));
}
// Your own fallback for a value that is not there — star does not choose one for you.
function from_text(text: String) -> Model {
let mutable count: Int = 0;
match json_parse(text) {
Error(e) => { }
Ok(v) => {
match json_at(v, "count") {
None => { }
Some(x) => { match json_as_int(x) { None => { } Some(n) => { count = n; } } }
}
}
}
return Model { count: count };
}
===
Then:
<script type="module">
import { mount } from './app.js'
mount({ wasm: 'app.wasm', root: document.getElementById('root'), component: 'app' })
</script>
Why you have to write those two. Nothing in Burxt holds state between two calls — a function sees its arguments and nothing else, which is what lets the compiler promise a view reaches nothing. So the host keeps the model and hands it back, and text is what can cross today.
It is a real cost, and it is the one thing on this page that exists because of a gap rather than a decision. When the language grows a value the host can hold as-is, these two go away and nothing else changes.
…build a page with several screens?
Make the route an enum, derive it from the path, and match on it:
===bx
use "std/string.bx";
enum Route { Home, Post(Int), Missing }
class Model { route: Route, path: String }
enum Msg { Navigate(String) }
pure function route_of(path: String) -> Route {
if path == "/" { return Route.Home; }
if string_starts_with(path, "/posts/") {
return Route.Post(string_to_int(substring(path, 7, len(path) - 7), 0));
}
return Route.Missing;
}
pure function update(msg: Msg, m: Model) -> Model {
match msg {
Navigate(to) => { return Model { route: route_of(to), path: to }; }
}
}
===
:props: model: Model
:!props:
:match: model.route
:case: Home
# Welcome
:!case:
:case: Post(id)
# Post {{ to_string(id) }}
:!case:
:case: Missing
:p:
Nothing at {{ model.path }}
:!p:
:!case:
:!match:
Copy examples/router.js beside your page. It intercepts in-page links, handles the back button,
and hands the path in.
Your links are real markup — :a: href=/posts/42 — so they work before any JavaScript runs
and the driver only upgrades them.
And because the route is an enum matched exhaustively, adding a screen and forgetting to draw it
fails the build rather than showing a blank page.
…find out why it refused something?
Every refusal is listed with its fix on When it says no.