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 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 of route patterns to resolvers.
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 — first by exact match, then by dynamic [param] pattern, then by [...spread] pattern. This lookup is fully synchronous.
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)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
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.
Runtime cost
- Startup — page modules load in parallel via
Promise.all. One-time cost resolved before first render. - Navigation — route matching is a synchronous loop. Static patterns hit an O(1) map; dynamic patterns are precompiled and cached per-route. Sub-millisecond for typical apps.
- Re-renders — the
$derivedonly re-evaluates whenpage.url.pathnamechanges or a tracked query signal fires. No polling, no intervals.
Patterns
Static label
The simplest pattern — return a fixed label.
Dynamic from load data
Read the label from page.data populated by a layout's load function. See Product #42.
Remote function
Call a server-side function inside the resolver — runs on the server, works with SSR.
Optimistic update
Combine a query with a command + .withOverride() for instant
client-side updates — no round-trip. See Playground.
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.
No breadcrumb
Omit the export entirely — the route is silently skipped in the breadcrumb trail. See About.