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 viapage.params[...rest]segments consume whatever is left over- everything else — static,
[param],[p=matcher], compoundfoo-[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
Maphit. Module loads are memoized, so revisiting a path awaits already-resolved promises. - Re-renders — the
$derivedre-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.