Host-controlled routing
The host application owns the URL. Coras pages emit navigation intents through
onNavigate; you turn each intent into a URL, push it to your router, and reflect
URL changes back into the mount with app.update().
The @coras-io/embed/url entry point provides framework-neutral helpers so you
do not write URL parsing glue for each framework.
Before you start
Section titled “Before you start”You need a working mount() call. See Get started if you have
not mounted a page yet.
Import the URL helpers from the /url entry point:
import { parseCorasUrl, buildCorasUrl, createCorasUrlState, isSameCorasUrlState,} from "@coras-io/embed/url";Parse and build URLs
Section titled “Parse and build URLs”By default the helpers put locale and currency in the path and represent a
details page as a bare id segment (for example /en-IE/EUR/abc123). The id is
percent-encoded so characters like /, ?, and # round-trip. A details id
that equals a reserved page segment (for example search) is not representable
in this shape and is not supported:
const state = parseCorasUrl("/en-IE/EUR/search?search=museum");// { page: "search", params: { search: "museum" }, locale: "en-IE", currency: "EUR" }
const url = buildCorasUrl({ page: "details", params: { id: "abc123", date: "2026-06-01" }, locale: "en-IE", currency: "EUR",});// "/en-IE/EUR/abc123?date=2026-06-01"Locale uses BCP 47 tags (en-IE) and currency uses ISO 4217 codes (EUR).
Aliases and casing are normalized during parsing.
If your router validates the locale and currency path params, make that
validation fall back rather than throw. A typed router runs its param parser
before its route guards, so a redirect declared in a guard never sees the
failure and the visitor gets the router’s error screen instead of your site. In
TanStack Router that is params.parse running ahead of beforeLoad.
Default page segments
Section titled “Default page segments”| Page | URL segment |
|---|---|
landing |
none |
details |
bare id (for example abc123) |
search |
search |
payment |
payment |
help |
help |
suggestion-widget |
widget |
Wire the routing loop
Section titled “Wire the routing loop”Bind the helpers to one strategy with createCorasUrlState, then connect both
directions: Coras intents out to your router, and router changes back into the
mount.
import { mount } from "@coras-io/embed";import { createCorasUrlState } from "@coras-io/embed/url";
const coras = createCorasUrlState();
const app = mount({ container, page: initialState.page, params: initialState.params, config, onNavigate(intent) { router.push(coras.build(intent)); // a link/selection -> a new history entry }, onStateChange(state) { router.replace(coras.build(state)); // an in-page change (e.g. a filter) -> replace },});
// When the URL changes, reflect it into the mount.function onRouteChange(url: string) { const next = coras.parse(url); if (!coras.isSame(next, app.state)) { app.update({ page: next.page, params: next.params, // An update merges into the mounted config, so these two keys are enough. config: { locale: next.locale, currency: next.currency }, }); }}coras.isSame skips the app.update() call when the parsed URL already matches
the mount’s current app.state, avoiding a redundant re-render. app.update()
never emits onNavigate, so it cannot loop back through the router on its own -
the guard is purely to skip needless renders and state churn.
A complete example
Section titled “A complete example”A full, framework-neutral integration using the History API: one persistent mount
kept in sync with the URL, with onNavigate pushing and onStateChange replacing.
It renders against the public sandbox, so it runs as-is. Component frameworks swap
the history.* / popstate calls for their router - see the
framework examples for that wiring.
import { mount, type CorasApp, type CorasConfig, type CorasNavigateDetail, type CorasStateChangeDetail,} from "@coras-io/embed";import { createCorasUrlState } from "@coras-io/embed/url";
const container = document.querySelector<HTMLElement>("#coras")!;
// Built once and reused. Locale and currency are patched in from the URL.const config: CorasConfig = { apiUrl: "https://sandbox.coras.io", distributorId: "a8405267cbcf4bd2b70114e618516645", assetsUrl: "https://assets.sandbox.coras.io/shared",};
// One URL strategy shared by build and parse, so they always agree on a URL.// The default puts locale and currency in the path: /:locale/:currency/...const coras = createCorasUrlState();
// Reflect a URL into the mount. Locale and currency travel in the URL, so they// are patched into config here: the mount switches language and currency only// when config says so. update() never re-emits onNavigate, so there is no loop.function applyUrl(href: string): void { const next = coras.parse(href); if (coras.isSame(next, app.state)) return; app.update({ page: next.page, params: next.params, config: { locale: next.locale ?? config.locale, currency: next.currency ?? config.currency, }, });}
// A navigation (link/selection) pushes a new URL; an in-page state change (a// filter, say) replaces the current one. An external href opens in a new tab.function syncUrl(detail: CorasNavigateDetail | CorasStateChangeDetail, replace: boolean,): void { if ("href" in detail && detail.href) { window.open(detail.href, "_blank", "noopener,noreferrer"); return; } const href = coras.build({ page: detail.page, params: detail.params ?? {}, locale: detail.locale, currency: detail.currency, }); history[replace ? "replaceState" : "pushState"](null, "", href); applyUrl(href);}
// Derive the initial page + params from the URL, so a deep link or refresh lands// on the right page. One persistent mount for every page - route changes call// app.update(), so the navbar, footer, and chrome stay put.const initial = coras.parse(location.href);const app: CorasApp = mount({ container, page: initial.page, params: initial.params, config: { ...config, locale: initial.locale ?? config.locale, currency: initial.currency ?? config.currency, }, onNavigate: (intent) => syncUrl(intent, false), onStateChange: (state) => syncUrl(state, true),});
// Back / forward (with a router: its route-change hook): re-read the URL.addEventListener("popstate", () => applyUrl(location.href));Avoid feedback loops
Section titled “Avoid feedback loops”Every onNavigate intent includes a source field:
"embed": navigation started inside a Coras page (a user clicked a result)."host": navigation came from your ownapp.navigate(page, params, { emitNavigate: true })call. A plainapp.navigate()call does not emit an intent.
Push to your router only for "embed" intents. Ignoring "host" intents
prevents Coras from re-driving the router for navigation you already initiated:
onNavigate(intent) { if (intent.source !== "embed") return; router.push(coras.build(intent));},Customize the URL strategy
Section titled “Customize the URL strategy”Pass a strategy object to createCorasUrlState (or to parseCorasUrl and
buildCorasUrl directly) to change how URLs are shaped.
| Option | Type | Default | Description |
|---|---|---|---|
basePath |
string |
"" |
Path prefix with no trailing slash, for example /events. |
localeInPath |
boolean |
true |
Put the locale in the path. |
currencyInPath |
boolean |
true |
Put the currency in the path. When false, currency is a query parameter. |
currencyParam |
string |
"currency" |
Query parameter name for currency when currencyInPath is false. |
reservedSegments |
Record<page, string> |
see table above | Override the path segment for each page. |
For example, basePath: "/events" prefixes every built URL and is stripped on parse:
const coras = createCorasUrlState({ basePath: "/events" });
coras.build({ page: "search", params: {}, locale: "en-IE", currency: "EUR" });// "/events/en-IE/EUR/search" (default, without basePath: "/en-IE/EUR/search")A non-empty basePath is what you reach for when Coras is
embedded in a larger app: it confines
Coras to a /events/*-style sub-tree of the host’s routes, leaving every other
route the host owns untouched.
To keep currency as a query parameter:
const coras = createCorasUrlState({ currencyInPath: false });
coras.build({ page: "search", params: {}, locale: "en-IE", currency: "EUR" });// "/en-IE/search?currency=EUR"Verify
Section titled “Verify”- Navigating inside a Coras page updates the browser URL through your router.
- Using browser back and forward, or editing the URL, updates the Coras page.
- The page does not reload or flicker on
"host"navigation, which confirms theisSameguard and thesourcecheck are working.
Reference
Section titled “Reference”createCorasUrlState(strategy?) returns helpers bound to one strategy:
| Method | Returns | Purpose |
|---|---|---|
parse(url) |
CorasUrlState |
Parse a relative or absolute URL into a Coras state. |
build(state) |
string |
Build a canonical URL from a Coras state. |
normalize(state) |
CorasUrlState |
Normalize locale and currency aliases and ensure a params object. |
isSame(a, b) |
boolean |
Compare two states to skip redundant updates. |
The standalone functions parseCorasUrl(url, strategy?) and
buildCorasUrl(state, strategy?) take the strategy as a second argument.
normalizeCorasUrlState(state) and isSameCorasUrlState(a, b) do not take a
strategy.
Use createCorasUrlState(strategy) for the routing loop: it binds one strategy
once, so every parse/build/isSame call stays consistent. Reach for the
standalone functions for one-off conversions where passing the strategy each
time (or none) is simpler.