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

Reference / UI

Navigation

Navigation.Routes · Navigation.CurrentPath · Navigation.Hash · Navigation.IsDirty · Navigation.Go(path) · Navigation.Open(url[, sameTab]) · Navigation.Close(path[, force]) · Navigation.Save(path) · Navigation.SetTitle(title)

The routes the user currently has open, and the verbs that move between them. Read `Navigation.Routes` in a layout to build a tab bar, a breadcrumb, or a back-stack; call `Navigation.Go` and `Navigation.Close` to act on them, or `Navigation.Open` to leave for an external address. The platform supplies the facts and the verbs, and renders none of the chrome.

stable9 examples compiled by CIuiroutingnavigationlayout

Summary#

Navigation is available in every component. It tells you which routes the user has open, which one they are looking at, and which ones hold unsaved edits — and it lets you open and close them.

It exists so you can build the chrome that names and switches between pages: a tab bar, a breadcrumb, a mobile back-stack, a page header. The platform ships none of that chrome. It ships the facts.

Signature#

Navigation.Routes         // the open routes, in the order they were opened
Navigation.CurrentPath    // the address of the active route, or null before the first page mounts
Navigation.Hash           // the URL fragment, decoded and without the leading `#` (empty when there is none)
Navigation.IsDirty        // does the CURRENT page hold uncommitted edits? (a bool)

Navigation.Go(path)               // navigate to a route OF THIS APP — checked against the pages it declares
Navigation.Open(url)              // open an EXTERNAL address in a new tab
Navigation.Open(url, sameTab: true)   // …or leave the app in THIS tab, replacing the page
Navigation.Close(path)            // close an open route; false if it has unsaved edits
Navigation.Close(path, force)     // close it regardless
Navigation.Save(path)             // commit that route's own edits, then close it
Navigation.SetTitle(title)        // name THIS route — what its tab and the browser title show

Each entry in Navigation.Routes has four fields:

FieldTypeMeaning
PathstringThe route's address (/users, /apps/3f2a) — what you pass back to Go and Close.
TitlestringThe page's name: whatever it last passed to Navigation.SetTitle, else its static [Title("…")], else null.
IsActiveboolThis is the route the user is looking at. Exactly one open route is active.
IsDirtyboolThe page has uncommitted edits — closing it would discard them.

Description#

Go is internal, Open is external#

The two navigation verbs split on one question: is the address a page of this app?

Go takes a route this app declares, so its literal argument is checked at compile time against the app's own [Page("…")] declarations — a typo names the routes that do exist instead of navigating to nothing. A route built at runtime ("/org/" + slug) is not checked, because there is nothing to check it against.

Open takes an address that is not a page of this app — an external site, a download link, a server endpoint — and is never checked against the app's routes. By default it opens beside your work, in a new tab. Pass sameTab: true to leave the app in the current tab instead:

Navigation.Go("/settings");                                   // a page of this app
Navigation.Open("https://docs.example.com");                  // a new tab, beside the app
Navigation.Open("/api/oauth/authorize?provider=Google", sameTab: true);   // leave, and come back

The sameTab case is for a redirect you return from. An OAuth authorize endpoint sends the visitor to the provider and brings them back to the app; a new tab cannot serve that, because the original tab sits there unchanged — still logged out. A download or a reference page wants the default: it opens beside the work in progress rather than replacing it.

Both forms of Open sanitize the address against the same scheme allow-list an href uses, so a javascript: address is refused however it reached the call.

What counts as "open"#

An outlet holds one page at a time unless you ask it to keep more (see routes and pages #retain). Either way, Navigation.Routes reports the routes that are currently open:

Outlet;                  // one open route — the page being viewed
Outlet(retain: true);    // every visited route stays open
Outlet(retain: 8);       // …up to 8

So a plain shop or marketing layout — which never opts into retention — can still read the current page's title for a header or a breadcrumb. It just always sees exactly one route.

Does my chrome re-render when a route changes?#

Reading Navigation in a component's render subscribes that component to it. When a route opens, closes, becomes active, or its page becomes dirty, the component re-renders. An unsaved-work dot next to a tab stays truthful without you polling anything.

A component that never mentions Navigation subscribes to nothing and costs nothing.

Can I read Navigation inside an action?#

An action reads Navigation the same way render does — where you are and whether the page is dirty are simply available, so a guard can ask for itself instead of being told:

[Page("/guarded")] [Render(CSR)]
component Guarded() {
  string blockedPath = "";
  action Leave(string path) {
    if (path != Navigation.CurrentPath && Navigation.IsDirty) { blockedPath = path; }
    else { Navigation.Go(path); }
  }
  render {
    Pressable(onClick: () => Leave("/users")) { Text("Users"); }
    if (blockedPath != "") { Text("Unsaved changes"); }
  }
}

The action sees the router as it is when the action runs — not as it was when the screen was drawn. That is the difference that matters: a value captured at render time and carried into a handler is a snapshot, and a stale one is exactly what a navigate-away guard must not act on. Only the target has to travel.

Is there anything to save? — Navigation.IsDirty#

Navigation.IsDirty is a bool: does the current page hold uncommitted edits? It is the active route's own IsDirty, read directly — the common case where a page gates its own Save on whether there's anything to save. Like the per-route field it reflects, it updates itself: bind a Save button's disabled to it and the button lights up the moment the user changes something and dims again after a successful save.

[Principal] entity User { string Email; }
entity Organization { [Required] string Name; string Slug; }

[Page("/settings")] [Render(CSR)]
component Settings(string slug) {
  var org = Organization.Single(o => o.Slug == slug);
  action Save() { UnitOfWork.Commit(); }
  live var canSave = Navigation.IsDirty && Validation.Violations.Count == 0;   // something to save, and it's valid
  render {
    Pressable(onClick: Save, disabled: !canSave) { Text("Save"); }
    Input(value: org.Name);
  }
}

It is false on a freshly-served page (SSR) and until the first edit.

Closing is where unsaved work is protected#

Switching between open routes never discards anything: the page you leave stays mounted, with its state and its half-typed form intact. Closing is the discard point, so Close is deliberately awkward about it:

[Page("/tabs")] [Render(CSR)]
component TabCloser() {
  string discarding = "";
  action CloseTab(string path) {
    // The page has unsaved edits. Ask, in your own words, in your own dialog.
    if (!Navigation.Close(path)) { discarding = path; }
  }
  action ConfirmDiscard() { Navigation.Close(discarding, true); discarding = ""; }
  render {
    Pressable(onClick: () => CloseTab("/users")) { Text("Close"); }
    if (discarding != "") { Pressable(onClick: ConfirmDiscard) { Text("Discard"); } }
  }
}

Navigation.Close(path) returns false — and changes nothing — when the page has uncommitted edits. Passing force closes it anyway. The platform never shows a confirmation dialog, because it has no business deciding what your app's dialogs look like or what they say.

Close also returns false for a path that isn't open, and for every path when your outlet retains nothing: a single open route has nothing to fall back to, so there is nothing a close could reveal.

Navigation.Save(path) is the other answer to the close prompt: it commits that route's own unsaved edits, then closes it. A page's edits live in the page's own unit of work, which your chrome can't reach — but the router can, so Save is how a "Save before closing?" prompt keeps the work instead of discarding it. So a close prompt has three natural answers — Save (Navigation.Save(path)), Discard (Navigation.Close(path, true)), and Keep editing (dismiss your prompt).

A save can be refused, and Navigation.Save tells you so: the values may break the entity's own rules, or the server may say no. It raises, so catch it and say why — the route stays open with the edits intact, and the user can fix them. A prompt that closed itself while the save failed would look exactly like a save that worked, which is the one thing it must never do.

[Page("/save-prompt")] [Render(CSR)]
component SavePrompt() {
  string closing = "/users";
  string saveError = "";
  action SaveAndClose() {
    try { Navigation.Save(closing); closing = ""; }
    catch (ValidationException ex) { saveError = ex.Message; }  // your prompt says why; the route stays open
  }
  action Discard() { Navigation.Close(closing, true); }         // throw them away, then close
  action KeepEditing() { closing = ""; }                        // just dismiss the prompt
  render {
    Pressable(onClick: SaveAndClose) { Text("Save"); }
    Pressable(onClick: Discard) { Text("Discard"); }
    Pressable(onClick: KeepEditing) { Text("Keep editing"); }
    Text(saveError);
  }
}

See Validation for what a refusal carries — ex.Violations names each field and the rule it broke, so a form can put the message beside the control that produced it.

Navigation.Go(path) moves to a route exactly as clicking an in-app link would: the address bar updates, Back and Forward work, and no page reload happens. It returns immediately — the destination's data loads on its own — so an action that navigates does not wait for the next page.

Leaving the app — an external url#

Navigation.Go moves within your app. Navigation.Open(url) leaves it: it opens an external address in a new tab, so the page the user is on — and anything they have typed into it — stays exactly where it was.

Navigation.Open("https://docs.example.com/getting-started");

Reach for it when the address cannot be known while the page renders, which is the case a plain link cannot cover. The usual example is a download: a file's link is minted on demand, is signed, and expires shortly after, so there is nothing to put in a link until the moment someone asks for it. Mint it in the action, then open it:

entity Document {
  [Required] string Name;
  security { allow create, read, update when IsAuthenticated; }
}

string GetDownloadUrl(Guid fileId) {                // signed and short-lived, so it cannot be a link
  return "https://files.example.com/" + fileId.ToString();
}

[Page("/files")] [Render(CSR)]
component Files() {
  var docs = Document.OrderBy(d => d.Name).ToList();
  action Download(Guid fileId) {
    string url = GetDownloadUrl(fileId);      // minted now, valid for a few minutes
    Navigation.Open(url);
  }
  render {
    foreach (var d in docs) {
      Pressable(onClick: () => Download(d.Id)) { Text(d.Name); }
    }
  }
}

When the address is known as the page renders, prefer an ordinary link — it is a real link, so the browser can offer "open in new tab", copy it, and show where it goes:

Link(href: "https://example.com") { Text("Example"); }

Only ordinary web addresses open — https:, http:, mailto: and tel:. Anything else is refused rather than opened, so a url that arrived from your data can never be turned into something executable. A refused url is reported in the browser console, so a link that does not open tells you why instead of failing silently.

What is open on a cold load, and after a refresh?#

When a browser first lands on one of your pages from the outside — a bookmark, a shared link, a brand-new tab — exactly one route is open: the one being served. Navigation.Routes reflects that on the server-rendered first paint too, so your chrome paints with the page instead of appearing a moment later.

A refresh of a running session is different. A retaining outlet (Outlet(retain: …)) remembers the tabs you had open, so a reload re-opens all of them — the served route stays active and the rest come back alongside it, in the same order — instead of collapsing to the single page the browser happened to reload. The tab set is what survives; a tab's unsaved edits are not (a reload discards the in-memory overlay, and the browser warns you first). A plain Outlet; keeps nothing, so it always lands on exactly the one served route.

What is a tab called? — Title and SetTitle#

Title comes from the page's [Title("…")]. A page that declares none reports null, and it is up to your chrome to decide what to show — its path, a fallback label, or nothing.

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

When the name depends on the page's data — an editor titled by the record it's editing — call Navigation.SetTitle(title) from an on change block. The reaction re-runs as the data changes, so the name follows it:

[Page("/orgs/{slug}")] [Render(CSR)]
component OrgEdit(string slug) {
  var org = Organization.Single(o => o.Slug == slug);
  on change { Navigation.SetTitle(org.Name); }     // the tab + the browser title track the org's name
  render { Input(value: org.Name); }
}

SetTitle names the route the calling page is mounted in. It feeds Navigation.Routes[].Title — so whatever chrome you render from that (a tab strip, a breadcrumb) follows along — and it drives the browser title of the active route. [Title("…")] remains the fallback: what's shown server-rendered, and before the reaction first runs. Setting the same title twice does nothing, so re-running the on change block costs nothing.

Reading what came after the #Navigation.Hash#

Navigation.Hash is everything after the # in the current address, URL-decoded and without the leading # — so at /oauth/complete#pending_oauth=abc it reads pending_oauth=abc. It is empty when there is no fragment, and empty server-side, where there is no location at all.

It exists for the one case a query string cannot cover: a fragment is never sent to the server, so it is the right carrier for a one-time bearer value handed back to a page by an external redirect. Pull a value out of it with Text.Split:

string token = Text.Split(Navigation.Hash, "pending_oauth=")[1];

Can I name something Navigation? — shadowing#

Navigation is an ambient name, not a keyword. A parameter, state member, or query named Navigation shadows it, exactly as a local variable shadows any other ambient. Nothing is reserved.

Examples#

Building a tab bar — there is no Tab component#

Everything below is ordinary Osy#. There is no Tab component in the platform, and this is the whole reason: you write the tab bar you actually want.

[Layout]
component AppShell() {
  string discarding = "";

  action Close(string path) {
    if (!Navigation.Close(path)) { discarding = path; }   // your own confirm, your own state
  }
  action ConfirmDiscard() { Navigation.Close(discarding, true); discarding = ""; }

  render {
    Row(gap: 1) {
      foreach (var r in Navigation.Routes) {
        Row(gap: 1) {
          Link(href: r.Path) { Text(r.Title); }            // navigating is what an anchor is for
          if (r.IsDirty) { Text("•"); }
          Pressable(onClick: () => Close(r.Path)) { Text("×"); }
        }
      }
    }
    if (discarding != "") { Pressable(onClick: ConfirmDiscard) { Text("Discard"); } }
    Outlet(retain: 8);
  }
}

Each × closes its own tab: () => Close(r.Path) binds the argument in the loop, so every row's handler carries that row's path. See component for the handler form.

A breadcrumb, with no retention at all#

[Layout]
component ShopShell() {
  render {
    Row(gap: 1) {
      Text("Shop");
      foreach (var r in Navigation.Routes) { Text(r.Title); }   // exactly one: the page being viewed
    }
    Outlet;
  }
}

See also#

Related

routes and pages

How a component becomes a page: it declares a route with `[Page("/catalog/{slug}")]`, and navigating to a matching path…

layout primitives

The built-in layout primitives and how they arrange children. `Stack` stacks children in a column, `Row` lays them in a…

component

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