svelte-crumbs v1.3.0
svelte-crumbs

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 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) 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 $derived only re-evaluates when page.url.pathname changes 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.

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