Navigation
Programmatic and link-based navigation in Bosia — goto(), beforeNavigate, afterNavigate, form actions, and the full-reload escape hatch.
Bosia ships four navigation patterns that mirror SvelteKit:
- Plain
<a href>links — the router intercepts. goto()— programmatic SPA navigation.- Form action
redirect(303, '/x')— server-driven post-submit nav. window.location.href— full browser reload (escape hatch).
The first three are SPA — they don't re-execute the entry script. The fourth tears down and re-hydrates.
Anchor links
<a href="/dashboard">Dashboard</a>Bosia's click handler intercepts plain anchor clicks and runs router.navigate(). Modifier-clicks (Cmd, Ctrl, middle-click) and target="_blank" fall through to the browser, so "open in new tab" works as expected. rel="external" and download also opt out, and hover prefetch skips them too, so a /logout loader never runs on hover. Pages opened from file:// (no host) always do full loads; an Electron app that wants client navigation should serve from a scheme with a host, like app://bundle.
`goto()`
import { goto } from "bosia/client";
await goto("/dashboard");
await goto("/login", { replaceState: true, invalidateAll: true });goto() returns a Promise that resolves after the navigation settles (loaders ran, components mounted).
Relative URLs resolve against the current page, like a link: goto("?page=2") stays in the app. goto("#x") is a same-page navigation. A javascript:, data: or vbscript: URL makes the Promise reject, like redirect() throws on one. Other sites are still allowed, so check a ?next= value stays on yours before passing it.
Options
| Option | Default | Description |
|---|---|---|
replaceState |
false |
Use history.replaceState instead of pushState. |
invalidateAll |
false |
Mark every loader dirty so the destination re-runs all server loaders. |
noScroll |
false |
Skip the default scroll-to-top after navigation. |
keepFocus |
false |
Reserved — not yet honored. |
state |
— | Reserved — Bosia has no shallow routing yet. |
If the URL matches the current route, goto() resolves without re-running loaders — immediately, or once a navigation to it that is still loading finishes. Pass invalidateAll: true to re-run them in place; the Promise resolves once fresh data is on the page.
Under a `BASE_PATH`
If the app is mounted under a sub-path, goto() takes the real, prefixed path. The route table is generated with the prefix and the router converts nothing, so an app-space path matches no route and falls through to a full page load onto the origin root:
import { base } from "bosia";
await goto(`${base}/dashboard`); // ✅
await goto("/dashboard"); // ❌ leaves the app when BASE_PATH is setbase is "" when the app runs at the origin root, so writing it is always safe. A literal <a href="/dashboard"> needs no change — markup is rewritten for you.
In development, a navigation that would have matched with the prefix logs a warning naming the path to use, instead of quietly leaving the app:
[bosia] "/dashboard" matched no route and is leaving the app — it is mounted at "/sso". Use "/sso/dashboard".The current page
page from bosia/client tells a component where it is. It updates on every navigation and is correct during SSR too.
<script lang="ts">
import { page } from "bosia/client";
</script>
<a href="/admin" class:active={page.url.pathname.startsWith("/admin")}>Admin</a>
{#if page.route.id?.startsWith("/(private)")}<SignOutButton />{/if}page.url— the currentURL.page.route.id— the route's folder with groups and params kept, e.g./(private)/dashboard/[id]. The same id hooks and loaders see.nullwhen no route matches (the 404 page).
Route params arrive as the params prop, not on page.
Scroll behavior
- Forward navigation (link,
goto(), form redirect) scrolls to the top — or to the#hashelement if the destination URL has one.goto(url, { noScroll: true })skips this for one navigation. - Back/forward (popstate) restores the scroll position the page had when you left it. The router saves the position per history entry and restores it after the destination page renders, so long listings don't snap to the top when the user presses Back. Positions survive reloads via
sessionStorage. - Same-page refresh (
invalidate(),invalidateAll(), ause:enhanceform that stays on the page) keeps the current scroll position. await goto()resumes after this scroll, so awindow.scrollTo()right after it sticks.
This is automatic — no app code needed.
Snapshots
Scroll restoration brings back the position; snapshots bring back the state — an in-progress form field, an open accordion, a search box. Export a snapshot object from +page.svelte:
<script lang="ts">
import type { Snapshot } from "./$types";
let comment = $state("");
export const snapshot: Snapshot<string> = {
capture: () => comment,
restore: (value) => (comment = value),
};
</script>
<textarea bind:value={comment}></textarea>capture() runs when the user navigates away; restore(value) runs when they come back via the browser's Back/Forward buttons. Snapshots also survive a full page reload — they persist to sessionStorage on unload.
Rules:
- The captured value must be JSON-serializable (no functions, Dates become strings) — that's how it survives reloads.
- Forward navigation to a page never restores — only Back/Forward (and reload) do, matching SvelteKit.
- Only
+page.sveltesupportssnapshot(not layouts).
Snapshot<T> is also importable from bosia/client.
Lifecycle hooks
beforeNavigate runs before each client-side navigation. The callback may call nav.cancel() to block the navigation (except on browser back/forward — see below).
<script lang="ts">
import { beforeNavigate, afterNavigate } from "bosia/client";
beforeNavigate((nav) => {
if (hasUnsavedChanges && !confirm("Discard changes?")) {
nav.cancel();
}
});
afterNavigate((nav) => {
console.log("navigated to", nav.to?.url.pathname);
});
</script>Both auto-unregister when the calling component is destroyed.
The Navigation object
interface Navigation {
from: { url: URL; params: Record<string, string> } | null;
to: { url: URL; params: Record<string, string> } | null;
type: "link" | "goto" | "popstate" | "form" | "enter";
willUnload: boolean;
cancel: () => void;
}type— how the navigation was triggered.willUnload—truewhen the browser is about to tear down the page (closed tab, external link). For these eventscancel()is a no-op; useaddEventListener("beforeunload", ...)directly to prompt the user.cancel()on apopstate(back/forward) navigation is also a no-op — the browser has already moved its history pointer by the time the listener runs.- A loader redirect is its own navigation of type
goto(orlink/form, as the nav it follows), even after Back — socancel()works on it.
Loading indicators
Bosia shows a thin progress bar at the top of the page during every client navigation. For a richer transition, drop a +loading.svelte into a route folder and Bosia renders that skeleton automatically while the destination loads — nested inside the shared layouts, no beforeNavigate wiring needed. See Loading Skeletons.
Form actions
Form actions that throw redirect(303, "/x") navigate via the router automatically when wrapped with use:enhance. Every loader re-runs (a login or logout changes layout data too), so a redirect back to the same page shows the updated data:
<form method="POST" action="?/logout" use:enhance>
<button>Log out</button>
</form>See Form Actions for the full story.
`window.location.href` — full reload
Use only when you genuinely need to tear down all client state (e.g. logging out, switching tenants in a multi-tenant app where a fresh runtime context is desired):
function hardLogout() {
document.cookie = "session=; Max-Age=0";
window.location.href = "/";
}For internal navigation prefer goto() — it's faster (no script re-parse, no re-hydration cost) and preserves the loader cache where possible.