Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

routes and pages

[Page("/catalog/{slug}")] component Catalog(slug) { … } — a component becomes a page with a route

How a component becomes a page: it declares a route with `[Page("/catalog/{slug}")]`, and navigating to a matching path serves it. Covers route templates and param capture, server- vs client-rendering, layouts and retained pages, in-app navigation, and the secure-by-default rule that a routed page requires sign-in.

stable1 example compiled by CIuiroutingauth

Summary#

A component becomes a page when it declares a route: [Page("/catalog/{slug}")]. When a browser navigates to a matching path, the platform serves that page, binding the captured route segments to the component's props. Your app ships no JavaScript of its own — you write components, and the platform runs them.

Signature#

[Page("/catalog/{slug}")]        // the route template; {slug} captures a segment
component Catalog(slug) { … }    // …and binds to the same-named prop

[Route("/catalog/{slug}")]       // `[Route]` is the same attribute under a second name
component Catalog(slug) { … }

[Page] and [Route] mean exactly the same thing. Both spellings are accepted everywhere a routed component is declared, and neither is preferred — pick one and stay with it in a codebase.

Description#

Putting a parameter in a URL — {name} segments#

The route is the string in [Page(...)]. Segments are matched literally except {name} segments, which capture one path segment and bind to the same-named component prop.

TemplatePathResult
/login/loginLogin, no params
//the root page
/catalog/{slug}/catalog/shoesCatalog, slug = "shoes"
/o/{order}/line/{line}/o/A1/line/7order = "A1", line = "7"
/plants/{id}/plants/3f2a…Edit, Guid id — see below

A captured segment is converted to the PROP'S DECLARED TYPE. The examples above all bind string because that is what those pages declare, but the type is yours to choose — Guid, int, long, decimal, bool and DateTime all bind, and a value that will not convert is a 404 rather than a page that throws:

[Page("/plants/{id}")]
component EditPlant(Guid id) { … }        // `id` arrives as a Guid — no parsing, no Guid.Parse

⚑ Worth stating because the absence read as an answer: every example on this page binds a string, line = "7" included, and a generated app duly took string id and called Guid.Parse on it by hand rather than "risk relying on automatic type conversion from the route" (measured, eval run 263). The conversion is not a risk; it is the contract.

Matching rules:

  • Segment counts must be equal — /catalog/{slug} does not match /catalog or /catalog/a/b.
  • Static segments match case-insensitively; captured values are URL-unescaped (red%20shoesred shoes).
  • The most specific template wins: a fully static template beats one with params, so /catalog/new resolves to the literal NewItem page, never to /catalog/{slug} with slug = "new".
  • A trailing slash is ignored (/notes and /notes/ resolve alike).
  • No path matches → the browser gets a 404.

Only routed components are candidates — a component without a [Page] is never a destination.

Render mode — SSR vs CSR#

A page declares how it is delivered with [Render(...)]. The mode decides whether the first response already contains the page's content or the browser renders it after loading:

  • [Render(CSR)] (client-side rendering) — the first response is empty of page content; the browser then renders the page. Simplest, but the first visible content waits on the browser to render.
  • [Render(SSR)] (server-side rendering) — the page's content is in the first response, so it is visible immediately. The page then hydrates in the browser: it becomes interactive (state, events, and live var updates) without re-fetching or re-drawing what was already shown.

Server-side and client-side rendering produce the same page from the same definition — a prop, an interpolated value, or an if condition evaluates identically either way, and an Input bound to state shows its current value in the initial content. SSR is purely an optimization for first paint; choosing it never changes what the page does, only how quickly its content appears.

Server rendering is for public pages. Only a page marked [AllowAnonymous] is pre-rendered. A hard browser navigation carries no session, so the server has no identity to render a private page under — and pre-rendering it as "nobody" would put a protected page's structure into content anyone can request. A page that requires sign-in is therefore delivered client-side and renders once the browser has a session. It still works exactly the same; it just isn't in the first response.

Data on a server-rendered page. A public page has its server-read data fetched and rendered on the server too, so a list of records is already in the first response — ideal for pages that must load fast or be indexed.

Chrome comes with it. A server-rendered page inside a [Layout] arrives with its layout already painted around it, so the shell is on screen before any JavaScript runs — see layouts below.

If a page uses something server rendering doesn't support yet, that page falls back to client-side rendering automatically — it still loads and works, it just isn't pre-rendered. You never get a broken page for choosing [Render(SSR)].

Sharing a sidebar or header across pages — [Layout]#

Most apps wrap their pages in shared chrome: a sidebar, a header, a tab bar. Rebuilding that on every navigation is both slow and visibly wrong — a sidebar shouldn't blink, and its scroll position and open sections shouldn't reset.

A layout is a component that wraps child pages. It marks itself with [Layout] and renders exactly one Outlet; — the place its child page appears. A page opts in by naming the layout it renders inside:

[Composable] component Sidebar() { render { Text("nav"); } }

[Layout]
component AppShell() {
  render {
    Sidebar();
    Outlet;          // the matched page renders here
  }
}

[Page("/users")] [Layout(AppShell)] [Render(CSR)] component UsersPage() { render { Text("Users"); } }
[Page("/teams")] [Layout(AppShell)] [Render(CSR)] component TeamsPage() { render { Text("Teams"); } }

[Page("/login")] [AllowAnonymous] [Render(CSR)] component LoginPage() { render { Text("Sign in"); } }   // no layout — renders bare

Navigating from /users to /teams keeps AppShell mounted. Only the outlet's child is replaced, so the shell's chrome, its state, its queries, and its scroll position all survive. Navigating to /login — which names no layout — tears the shell down; coming back rebuilds it.

[Layout(AppShell)] is a checked identifier, not a string: naming something that isn't a layout is a compile error, exactly like naming an undeclared policy in [Authorize(...)]. More mistakes are caught at compile time rather than becoming a blank screen:

  • an Outlet; in a component that isn't a [Layout];
  • a [Layout] that renders no outlet (its child would have nowhere to go) or more than one;
  • a component using itself as its layout;
  • a public page inside a protected layout. An anonymous visitor loads a page's chrome as well as its body, so an [AllowAnonymous] page can only render inside a layout that is itself [AllowAnonymous] (or [Composable]). Otherwise the page could never paint for the visitor it was made public for.

Does the layout render on the server too?#

When a [Render(SSR)] page declares a layout, the server renders the layout around it — the shell and the page arrive together, in one response. The browser paints the whole thing before the app's JavaScript has loaded, and when the client takes over it adopts what was painted rather than rebuilding it. Nothing flashes and nothing is drawn twice. This is the reason a layout is compile-checked so heavily: the page the server paints and the page the client builds have to be the same page.

Keeping more than one page alive#

By default an outlet holds one page at a time: navigating away disposes the page you left. That's what a shop, a marketing site, or any ordinary web app wants, and it costs nothing.

Some apps want the opposite. An admin console where you keep several records open; a mobile shell with a back-stack; a wizard whose steps remember what you typed. For those, tell the outlet to retain the routes you visit:

Outlet;                  // one page at a time — disposed on navigate (the default)
Outlet(retain: true);    // every visited route stays alive, hidden, exactly one visible
Outlet(retain: 8);       // …up to 8; beyond that the least-recently-used one is dropped

A retained page is mounted, just not visible. Its state, its scroll position, and any unsaved edits survive — so returning to it is instant and nothing you typed is lost. Its live queries go quiet while it's hidden and catch up in a single read when you come back, so keeping pages around doesn't multiply your data traffic.

The platform has no opinion about how you present this. There is no tab component, no tab bar, no back-stack widget. Retention is the mechanism; you decide whether it looks like tabs, a stack, a wizard, or nothing at all, and you build that chrome from ordinary components in your layout. Navigation is what you read to build it.

Two rules the platform does enforce, because they protect the user's work:

  • A page with unsaved edits is never dropped to satisfy a retain: N cap. The cap is exceeded instead. Losing someone's half-finished form to reclaim memory is never the right trade.
  • Closing a page with unsaved edits requires an explicit confirmation from your app. The platform will refuse the close and tell you the page is dirty; showing the dialog (and what it says) is yours.

Three rules about layouts#

  • A layout is not a route. It has no [Page] of its own and never appears as a destination; it exists only to wrap pages.
  • A layout wrapping public pages must itself be public. Page structure is fetched under the same secure-by-default rule as everything else, so a layout around [AllowAnonymous] pages needs [AllowAnonymous] too. A layout around protected pages simply stays protected.
  • Layouts don't nest yet. A [Layout] that declares its own [Layout(...)] is a compile error rather than silently rendering without its parent.

Once an app is running, moving between its pages does not reload the browser. A link to one of the app's own routes swaps that page's content in place, leaving the rest of the app — and everything it has already loaded — alive.

The address bar still holds a real URL, never a # fragment. So every page in your app is:

  • bookmarkable and shareable — pasting the URL into a fresh tab opens that exact page;
  • navigable with Back and Forward, which move between pages rather than out of the app;
  • refreshable — reloading a deep URL re-serves that page, not the home page.

Route params keep working exactly as they do on a first load: /catalog/{slug} navigated to as /catalog/shoes binds shoes to the page's slug parameter.

To navigate from code — and to read which routes are open, which is active, and which hold unsaved edits — use Navigation.

What still performs a full browser navigation#

Only in-app links are taken over. Everything a user expects the browser to handle, the browser still handles:

  • a link to a path that isn't one of your routes (a file, an API path, a 404);
  • an external link, or one marked rel="external";
  • a link with a target (e.g. opening in a new tab) or a download;
  • a modified click — ⌘/Ctrl-click, Shift-click, Alt-click, or a middle-click.

That last one matters: "open in a new tab" keeps working on every link in your app.

Navigating to a page that requires authentication while signed out sends the visitor to your login page, carrying a return address so they land back where they were headed. This is a convenience, not the security boundary — the server independently refuses to serve a protected page's structure or data to a caller who isn't allowed it, so a page can never leak by a client-side check being skipped.

Auth model — secure by default#

A routed component requires an authenticated principal BY DEFAULT. A page opts out with [AllowAnonymous] — the explicit, compiler-visible declaration that it is public. This is the platform's "safe by default" posture applied to routing: the secure configuration is the zero-config one, and a page can never leak by a forgotten attribute (the failure mode of the inverse "[Authorize] opts in" model).

  • component Home() — protected: requires an authenticated principal.
  • [AllowAnonymous] component Home() — public: any caller, including anonymous, may view it.
  • [Authorize(Managers)] component Home() — protected and the principal must satisfy the named policy Managers (a compile-checked reference, not a string; see page authorization (policies)).

The real authorization teeth are at the data boundary, not at page delivery. A hard browser navigation carries no credential (the platform authenticates with a Bearer token, not a cookie), so — exactly as with any single-page app — the server cannot tell a logged-in user from an anonymous one at page-load. So delivery of the page shell is public (a bootstrapper contains no app data), and the [AllowAnonymous]-or-not fact rides along only so the browser can redirect an unauthenticated visitor to the login route before it tries to fetch a page it can't have. The server then independently refuses to serve a protected page's structure or its data to a caller who isn't allowed it — so a malicious client can load a public shell but can never obtain a protected page's tree or rows.

The login flow itself is [AllowAnonymous], so it loads for a signed-out visitor. The user submits credentials, the app's own auth surface issues them a session, and every subsequent page and data request is made under it.

See also#

  • component — the [Page] / [Authorize] / [Render] attributes on the component.
  • Navigation — reading the open routes and navigating from code.
  • [[ui-component#render-tree]] — the render tree the client walks once the component is mounted.
  • <span class="planned" title="this page is planned and not written yet">ui-query-member</span> — how a mounted page's server-read members read data.

Related

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…