How svelte-crumbs works
A deep dive into the internals so you know exactly what runs, when it runs, and why it is safe.
Architecture
1. Module scanning
At startup, buildBreadcrumbMap() calls import.meta.glob('/src/routes/**/+page.svelte') in non-eager mode. Vite returns a record of lazy loader functions — one per page file.
No component code is imported at this point; only the file paths are known.
All loaders are then invoked in parallel via Promise.all.
Each loader resolves to the page module, and only the module-level breadcrumb export is read.
Pages without a breadcrumb export are skipped.
The result is a flat Map<string, BreadcrumbResolver> mapping route patterns to resolver functions.
2. Route matching
When the URL changes, getResolversForRoute() walks path segments from root to leaf. For /products/42/edit it checks /, /products, /products/42, and /products/42/edit.
Each segment is looked up in the map — first by exact match, then by dynamic [param] pattern, and finally by [...spread] pattern.
This lookup is fully synchronous — no async work, no awaits.
3. Resolution
The collected resolvers are called in parallel. Each receives a snapshot of page state (params, data, url)
and the breadcrumb's own URL path. They return { label, icon? } or undefined to skip a segment.
SSR safety
SvelteKit's page proxy
is tied to the current request via component context. Reading it after an await on the server throws
because the rendering context is gone. svelte-crumbs handles this in two ways:
- Snapshot before await —
pagestate is captured into a plain object synchronously, before the one-timeawait readythat loads modules on the first render. Resolvers receive this safe snapshot, never the live proxy. - Cached pathname via
$derived— the route resolver reads a cached$derived(page.url.pathname)that was evaluated in the rendering context. After the await boundary, Svelte returns the cached value without re-reading the proxy.
The result: full SSR support with no "Cannot read page.params outside rendering" errors,
and no leaked state between requests.
Reactive tracking
The resolver map is built once (async), then kept in a $state-gated $derived.
After the initial load, there are no awaits between the $derived read and the resolve() call.
This means Svelte's fine-grained tracking reaches into every resolver:
if a resolver calls a reactive query (like SvelteKit's query()), the signal is tracked
and the breadcrumbs automatically re-resolve when it changes — including
optimistic updates via .withOverride().
Performance impact
Bundle size
import.meta.glob runs in non-eager mode. Vite code-splits each page module separately — the
breadcrumb map only pulls in the thin module-level breadcrumb export, not the
full component tree. Pages without a breadcrumb export are skipped entirely.
Runtime cost
- Startup — all page modules are loaded in parallel via
Promise.all. This is a one-time cost that resolves before the first render completes. - Navigation — route matching is a synchronous loop over path segments with O(n) pattern fallback where n is the number of breadcrumb-exporting pages. For typical apps (tens of routes) this is sub-millisecond.
- Re-renders — the
$derivedonly re-evaluates whenpage.url.pathnamechanges or a tracked query signal fires. There is no polling, no intervals, and no unnecessary work.
Quick start
Root layout
Call createBreadcrumbs() once
in your root layout. It scans all pages, resolves the matching breadcrumbs for the
current route, and returns a reactive array.
<script module lang="ts">
<script lang="ts">
import { createBreadcrumbs } from 'svelte-crumbs';
const getBreadcrumbs = createBreadcrumbs();
const crumbs = $derived(await getBreadcrumbs());
</script>
{#each crumbs as crumb, i}
{#if i > 0} / {/if}
<a href={crumb.url}>{crumb.label}</a>
{/each}
</script>Page breadcrumb
Export a breadcrumb from
any +page.svelte module script.
The resolver receives the current page state and the
breadcrumb's own URL.
<script module lang="ts">
export const breadcrumb: BreadcrumbMeta = async (page) => ({
label: page.data.product.name
});
</script>Patterns
Static label
The simplest pattern — return a fixed label. Used on Products, Docs, and this page.
<script module lang="ts">
export const breadcrumb: BreadcrumbMeta = async () => ({
label: 'Home'
});
</script>Dynamic from load data
Read the label from page.data populated by a layout's load function.
See Product #42.
<script module lang="ts">
export const breadcrumb: BreadcrumbMeta = async (page) => ({
label: page.data.product.name
});
</script>Remote function
Call a server-side function inside the resolver — runs on the server, works with SSR. See Getting Started.
<script module lang="ts">
import { getDocTitle } from '$lib/docs.remote.js';
export const breadcrumb: BreadcrumbMeta = async (page) => ({
label: await getDocTitle(page.params.slug ?? '')
});
</script>Optimistic update
Combine a query with a command + .withOverride() for instant
client-side updates — no round-trip.
See Playground.
<script module lang="ts">
export const breadcrumb: BreadcrumbMeta = async () => ({
label: await getNickname()
});
// on save — breadcrumb updates instantly
setNickname(value).updates(getNickname().withOverride(() => value));
</script>Spread / catch-all routes
Use the { routes } form to
define breadcrumbs for multiple route patterns from a single [...rest] page.
The second argument (url) is
the breadcrumb's own path, not the full URL.
See Spread routes.
<script module lang="ts">
export const breadcrumb: BreadcrumbMeta = {
routes: {
'/spread': async () => ({ label: 'Spread' }),
'/spread/[...rest]': async (_page, url) => ({
label: url.split('/').pop() ?? 'overview'
})
}
};
</script>No breadcrumb
Omit the export entirely — the route is silently skipped in the breadcrumb trail. See About.