Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

routes and pages

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

How a component becomes a page: it declares a route with `[Route("/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.

stable4 examples compiled by CIuiroutingauth

Summary#

A component becomes a page when it declares a route: [Route("/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#

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

[Route] is the one spelling. [Page("/catalog/{slug}")] — the word other frameworks use — is refused, and the refusal writes out the [Route(...)] with your address in it.

Description#

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

The route is the string in [Route(...)]. 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"
/fossils/{id}/fossils/3f2a…Edit, Guid id — see below
/orders/{order}/orders/3f2a…OrderPage, Order order — the ROW, 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. A Guid, number or bool segment that will not convert is a 404 rather than a page that runs with a value that cannot exist — /fossils/abc for the page below is the app's not-found page, on a hard navigation and an in-app one alike:

[Route("/fossils/{id}")]
component EditFossil(Guid id) { … }        // `id` arrives as a Guid — no parsing, no Guid.Parse

The typed value is the same on both sides of the page: a Guid compares with a row's Id in a query, passes to a server function that takes a Guid, and prints and links as itself; an int does arithmetic as a number in the browser as well as on the server.

entity Fossil {
  [MaxLength(80)] string Name;
  int Drawer;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

[AllowAnonymous]
public string NameOf(Guid id) {
  var p = Fossil.Where(x => x.Id == id).FirstOrDefault();
  return p == null ? "" : p.Name;
}

[Route("/fossils/{id}")] [AllowAnonymous] [Render(CSR)]
component FossilPage(Guid id) {
  live var fossil = Fossil.Where(p => p.Id == id).FirstOrDefault();   // the Guid, compared with the row's Id
  string named = "";
  action Name() { named = NameOf(id); }                              // …and handed to a server function
  render {
    Stack {
      if (fossil != null) {
        Text(fossil.Name);
        Link($"/drawer/{fossil.Drawer}") { Text("Same drawer"); }
      }
      Button("Name it", onPress: Name);
      Text($"named {named}");
    }
  }
}

[Route("/drawer/{drawer}")] [AllowAnonymous] [Render(CSR)]
component DrawerPage(int drawer) {
  live var fossils = Fossil.Where(p => p.Drawer == drawer).OrderBy(p => p.Name).Take(50).ToList();
  render {
    Stack {
      Text($"Drawer {drawer}, next is {drawer + 1}");                     // a number: 3 + 1 is 4
      foreach (var p in fossils) { Link($"/fossils/{p.Id}") { Text(p.Name); } }   // a link built from a row's Id
    }
  }
}

[Route("/")] [AllowAnonymous] [Render(CSR)]
component Home() {
  render { Link("/drawer/1") { Text("Drawer 1"); } }
}

A page about one row — take the ROW#

A page that shows one record takes the record, not its id. Type the parameter as the entity and the address carries its id; the page receives the row:

[Route("/orders/{order}")]
component OrderPage(Order order) { render { Text(order.Number); } }   // `order` is the row — no query, no null check

What that one parameter does, so you write none of it:

  • The row is read as the person looking, through the entity's ordinary security { } rules — the same read any live var makes.
  • What the page reads through the row is fetched with it. order.Customer.Name and foreach (var l in order.Lines) arrive in the same request as the row; there is no .Include to write and no second round trip.
  • It is live. A change to the row — this person's or anyone's — reaches the open page, as a live var does.
  • The page is drawn only with its row. Its render, its fields' initializers and its on mount all see the row, so none of them needs an if (order != null).
  • No row, one answer. An id that is not an id (/orders/abc), an id no row has, and a row this person may not read all give the app's not-found page (app.Ui { NotFoundSurface = … }, or the platform's own) — the same page for all three, so it never says which rows exist. A row that goes away while the page is open turns into that page too.

Linking to it — name the page and pass the row. OrderPage(order) written where an address goes is that page's address, with the row's id in it: Link(OrderPage(o)) { … }, Navigation.Go(OrderPage(order)), and anywhere else a path is text. The arguments are checked against the page's parameters like any call's, so an address can no longer drift from the route it points at. Passing an id where the page takes the row is refused with both ways out — pass the row, or write the address yourself ($"/orders/{id}").

entity Customer {
  [MaxLength(80)] string Name;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

entity Order {
  [MaxLength(40)] string Number;
  Customer Customer;
  [ForeignKey(Order)] Line[] Lines;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

entity Line {
  Order Order;
  [MaxLength(80)] string Item;
  security { allow read when IsAnonymous || IsAuthenticated; }
}

[Route("/orders/{order}")] [AllowAnonymous] [Render(CSR)]
component OrderPage(Order order) {
  render {
    Stack {
      Text($"Order {order.Number} for {order.Customer.Name}");   // read through the row — fetched with it
      foreach (var l in order.Lines) { Text(l.Item); }
    }
  }
}

[Route("/")] [AllowAnonymous] [Render(CSR)]
component Orders() {
  live var orders = Order.OrderBy(o => o.Number).Take(50).ToList();
  live var latest = Order.OrderByDescending(o => o.Number).FirstOrDefault();
  action OpenLatest() { Navigation.Go(OrderPage(latest)); }      // the page, and the row it takes
  render {
    Stack {
      foreach (var o in orders) { Link(OrderPage(o)) { Text(o.Number); } }
      Button("Open the latest", onPress: OpenLatest);
    }
  }
}

Reach for Guid id (above) when the page is about an id rather than a row — one that may not exist yet, or that it hands to a server function to decide.

⚑ A public page that shows "not found" for a row you know exists is usually refused, not mis-routed. When the row's entity grants no read to a signed-out visitor, the page's query answers 403 and the page draws its own empty branch. The refusal says which entity and the rule to write — see [[security-public-reads#grants]].

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

A segment may carry a LINK — type the prop as a grant. component Letters(Preferences link) on [Route("/preferences/{link}")] receives a link the platform has already checked — signature, expiry, revocation — in ROUTING, before the page is served. A link that does not check out never reaches the page: the visitor gets a 404 with the app's InvalidLinkSurface (see UI surfaces (app.Ui)) or a plain This link has expired or is not valid. A grant prop must be a segment of the route; the compiler refuses one that is not.

[Route("/preferences/{link}")] [AllowAnonymous]
component Letters(Preferences link) { … }     // checked before the page is served; pass `link` to server functions

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%20shoes → red 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 [Route] 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; on each render path — 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
  }
}

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

[Route("/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 on the same render path — two in one branch, or one inside a foreach;
  • 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.

One outlet per path, so a branch may carry its own. The branches of an if are alternatives, so each may hold the outlet in different chrome. When the condition changes with a page open, the page moves into the new branch's outlet — it is not torn down:

[Principal] entity Member {
  [Unique, MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

policy IsStaff => IsAuthenticated;

[Layout]
component Shell() {
  render {
    if (IsStaff) {
      Stack { Text("Staff tools"); Outlet; }
    } else {
      Box { Text("Welcome"); Outlet; }
    }
  }
}

[Route("/")] [Layout(Shell)] [Render(CSR)] component Home() { render { Text("Home"); } }

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

The count is stored in the compiled layout, so it must be known when the app compiles: true, false, a whole number, arithmetic on those, or a const of the layout itself — the C# rule for a constant. A function call, a live field or a parameter is refused, and the refusal says so. 0 and negative numbers are refused too: they would keep nothing, so write Outlet;.

const int OpenTabs = 8;          // a member of the layout component, named once
Outlet(retain: OpenTabs);        // the same as retain: 8
Outlet(retain: OpenTabs * 2);    // arithmetic on constants folds too

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.

What a layout is not, and what it must be#

  • A layout is not a route. It has no [Route] 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;
  • a link to a page that takes a grant link — always a full load, so the server checks the link every time, and the page is served with Referrer-Policy: no-referrer so the link never leaves in a Referer header.

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.

A package's pages — served, and taken over by name#

A [Route] a package declares is served in every app that uses the package, at the package's route. A kit that owns a flow owns its addresses — Osysharp.UserAccounts serves /login, /sign-up, /forgot-password, /reset-password/{token}, /verify-email/{token} and /account, which its mail and its forms link to — so an app writes no line to mount them (The UserAccounts kit — sign-in done properly, once). osy model --json lists them under packagePages.

A package's page is authorized exactly as the same page in app source: its own [AllowAnonymous] opens it, its own [Authorize(P)] narrows it, and neither means a signed-in caller is required. Being a package's opens nothing.

An app takes a package's page over by declaring a component of the same name — the rule an app's own Card already follows over a kit's Card. With a [Route] of its own it is served there instead, at the package's route or moved to another; without one, the package's address is not served:

[Route("/login")] [AllowAnonymous] [Render(CSR)]     // the kit's SignInPage, replaced by the app's own
component SignInPage() { render { MyFrame { SignInForm(); } } }

[Route("/me")] [Render(CSR)]                         // the kit's AccountPage, moved to /me
component AccountPage() { render { AccountScreen(); } }

component VerifyEmailPage() { render { Text("-"); } }   // no [Route]: /verify-email/{token} is no longer served

Two claims on one address are refused, naming both — an app page on a package page's route under another name, or two packages' pages on one route. Nothing could decide between them, so the compiler asks you to: take one over (and move it, or leave it unserved), or give your own page another route.

See also#

  • component — the [Route] / [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.
  • grants (a link that opens one row) — a page whose address carries a signed link to one row.
  • The UserAccounts kit — sign-in done properly, once — a kit that serves its own pages: sign-in, sign-up, reset, verification, account.

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`…

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…