How svelte-crumbs works

What runs, when it runs, and why it is safe on the server.

Architecture

1. Route index

The first call to getCrumbs() builds a route index from import.meta.glob('/src/routes/**/+page.svelte'). Only the keys of the glob record are used at this point — each file path becomes a group-free route id, so /src/routes/(app)/products/[id]/+page.svelte turns into /products/[id]. Pure string work over the key list: fully synchronous, and no module is loaded at startup.

2. Walking the route

On every navigation, getCrumbs() reads page.route.id and page.url.pathname synchronously. SvelteKit has already decided which route matched, so there is no custom pattern matcher — the walk pairs route-id segments with pathname segments to learn how many pathname segments each route segment consumed:

  • (group) segments consume nothing
  • [[optional]] segments consume 0 or 1, disambiguated via page.params
  • [...rest] segments consume whatever is left over
  • everything else — static, [param], [p=matcher], compound foo-[c] — consumes exactly 1

The walk yields one level per segment, each pairing a route-id prefix with a concrete pathname prefix. For /products/42/edit (route id /products/[productId]/edit) the levels are /, /products, /products/42, and /products/42/edit. Error pages rendered without a matched route (page.route.id === null) short-circuit to an empty trail.

3. Lazy module loading

Only the page modules whose route id is one of the walked prefixes are imported — for /products/42/edit that is at most the pages at /, /products, /products/[productId], and /products/[productId]/edit. Each load is memoized, so a module is imported at most once for the lifetime of the app. A loaded module registers its breadcrumb export into the index — keyed by its route id (function form) or by its normalized { routes } keys.

4. Resolution

Each level is checked with two exact Map lookups — a concrete-pathname key wins over a route-id key, so a { routes } entry like '/spread' takes precedence over the route id that matched the same level. The collected resolvers then run in parallel. Each receives a snapshot of page state (params, data, url) and the breadcrumb's own URL path, and returns { label, icon? } or undefined to skip a segment. Errors are isolated per resolver: a throwing resolver is logged and skipped instead of taking down the whole trail.

SSR safety

SvelteKit's page proxy is bound to the request through component context, so reading it after an await on the server throws — the rendering context is gone by then. svelte-crumbs therefore reads everything it needs — route.id, url, params, data — synchronously, before the first await, and captures it into a plain object. Resolvers get the snapshot, never the live proxy.

The result: SSR with no "Cannot read page.params outside rendering" errors, and no leaked state between requests — the route index holds only build-static loaders and registered resolvers.

Reactive tracking

Because the page reads happen synchronously inside the call, wrapping it in $derived(await getCrumbs()) makes navigation a tracked dependency — the trail re-resolves whenever the route changes, with no polling. On the client, Svelte's async-derived tracking also reaches into the resolvers: a reactive query (SvelteKit's query()) is tracked, and the trail re-resolves when it changes — optimistic .withOverride() included.

Performance

Bundle size

import.meta.glob runs non-eager, so the index holds only lazy loader references and nothing else blocks first paint. When a trail resolves, it loads only the modules along the current path.

What those modules are depends on the Vite plugin. Without it, each loader imports the +page.svelte itself, so resolving a crumb downloads that page's chunk and its dependencies. With crumbs() from svelte-crumbs/vite, each loader imports only the page's <script module>, served as its own small module next to the page.

Warmup

After hydration, an idle-time warmup loads the remaining breadcrumb modules in the background and re-runs the trail once, warm. From then on every resolution is fully synchronous, which is what keeps reactive reads inside resolvers (remote queries, optimistic overrides) tracked, and a first visit to any route updates the trail with no extra request.

With the plugin, warming everything costs a few hundred bytes per route. Without it, each module is a whole page chunk, so the warmup downloads every page in the app. To avoid that, add the plugin or set warmup: 'visited'. Then only the routes the user walks are loaded, and the trail re-runs once after each cold load instead. The catch: on a first visit, the previous trail stays up until that route's modules arrive.

Runtime cost

  • Startup — building the route index is a synchronous loop over the glob keys. String work only; no modules load until a trail needs them.
  • Navigation — the segment walk is a synchronous loop over the matched route id (O(depth)), and every resolver lookup is an exact Map hit. Module loads are memoized, so revisiting a path awaits already-resolved promises.
  • Re-renders — the $derived re-evaluates only when a tracked dependency changes: the route, the pathname, or a query signal used by a resolver. No polling, no intervals.

Patterns

Every resolver pattern — static, load data, remote function, optimistic update, spread routes, no breadcrumb — lives in the API reference.

Internationalized routes — stripping a locale prefix with transformPath, or letting an optional [[lang]] segment pick a translated label — have their own guide: Internationalized routes.