Skip to content

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.

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";

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.

Page URL segment
landing none
details bare id (for example abc123)
search search
payment payment
help help
suggestion-widget widget

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 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));

Every onNavigate intent includes a source field:

  • "embed": navigation started inside a Coras page (a user clicked a result).
  • "host": navigation came from your own app.navigate(page, params, { emitNavigate: true }) call. A plain app.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));
},

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"
  • 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 the isSame guard and the source check are working.

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.