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:

  1. Plain <a href> links — the router intercepts.
  2. goto() — programmatic SPA navigation.
  3. Form action redirect(303, '/x') — server-driven post-submit nav.
  4. 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.

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

`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).

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 immediately without re-running loaders. The invalidateAll: true option is not honored on same-path calls — call invalidateAll() directly (imported from bosia/client) to refresh in place.

Scroll behavior

  • Forward navigation (link, goto(), form redirect) scrolls to the top — or to the #hash element 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.

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.svelte supports snapshot (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.
  • willUnloadtrue when the browser is about to tear down the page (closed tab, external link). For these events cancel() is a no-op; use addEventListener("beforeunload", ...) directly to prompt the user.
  • cancel() on a popstate (back/forward) navigation is also a no-op — the browser has already moved its history pointer by the time the listener runs.

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:

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