v1.5.0

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 awaitpage state is captured into a plain object synchronously, before the one-time await ready that 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 $derived only re-evaluates when page.url.pathname changes 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.

MIT © 2026 svelte-crumbs (use this however you like)