Automatic, SSR-ready breadcrumbs for SvelteKit via route-level metadata exports. Zero config, fully reactive, server-rendered with top-level await.
Svelte 5 + SvelteKit 2 only. Data layer only — bring your own rendering.
npm install svelte-crumbs
This library relies on Svelte's experimental async compiler option for top-level await in components. This is required.
// svelte.config.js
const config = {
kit: {
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
}
};
To also use remote functions in your breadcrumb resolvers, add
kit.experimental.remoteFunctions: trueas well.
<!-- src/routes/products/+page.svelte -->
<script lang="ts" module>
import type { BreadcrumbMeta } from 'svelte-crumbs';
export const breadcrumb: BreadcrumbMeta = async () => ({
label: 'Products'
});
</script>
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { createBreadcrumbs } from 'svelte-crumbs';
const getBreadcrumbs = createBreadcrumbs();
const crumbs = $derived(await getBreadcrumbs());
</script>
<nav>
{#each crumbs as crumb, i}
{#if i > 0} / {/if}
<a href={crumb.url}>{crumb.label}</a>
{/each}
</nav>
No {#await} blocks needed. Breadcrumbs resolve during SSR and update reactively on client navigation.
<script lang="ts" module>
import type { BreadcrumbMeta } from 'svelte-crumbs';
export const breadcrumb: BreadcrumbMeta = async () => ({
label: 'Settings'
});
</script>
The breadcrumb resolver receives the full page object, including page.data. Use +layout.server.ts (not +page.server.ts) so the data is available to child routes' breadcrumbs too:
// src/routes/products/[id]/+layout.server.ts
export async function load({ params }) {
const product = await db.products.find(params.id);
return { product };
}
<!-- src/routes/products/[id]/+page.svelte -->
<script lang="ts" module>
import type { BreadcrumbMeta } from 'svelte-crumbs';
export const breadcrumb: BreadcrumbMeta = async (page) => ({
label: page.data.product.name
});
</script>
<script lang="ts">
let { data } = $props();
</script>
<h1>{data.product.name}</h1>
Why
+layout.server.ts? Breadcrumb resolvers run for every segment of the URL. When visiting/products/42/edit, the resolver for/products/[id]fires too. If you put the load in+page.server.ts,page.dataon child routes won't haveproduct— layout data cascades down, page data doesn't.
Breadcrumb resolvers can call remote functions that run on the server:
// src/lib/products.remote.ts
import { query } from '$app/server';
export const getProductName = query('unchecked', async (id: string) => {
const product = await db.products.find(id);
return product.name;
});
<!-- src/routes/products/[id]/+page.svelte -->
<script lang="ts" module>
import type { BreadcrumbMeta } from 'svelte-crumbs';
import { getProductName } from '$lib/products.remote';
export const breadcrumb: BreadcrumbMeta = async (page) => ({
label: await getProductName(page.params.id ?? '')
});
</script>
For dynamic routes that map to known paths:
<script lang="ts" module>
import type { BreadcrumbMeta } from 'svelte-crumbs';
export const breadcrumb: BreadcrumbMeta = {
routes: {
'/docs/getting-started': async () => ({ label: 'Getting Started' }),
'/docs/api-reference': async () => ({ label: 'API Reference' })
}
};
</script>
<script lang="ts" module>
import type { BreadcrumbMeta } from 'svelte-crumbs';
import HomeIcon from './HomeIcon.svelte';
export const breadcrumb: BreadcrumbMeta = async () => ({
label: 'Home',
icon: HomeIcon
});
</script>
Since svelte-crumbs only provides data, you render however you want:
<script lang="ts">
import { createBreadcrumbs } from 'svelte-crumbs';
const getBreadcrumbs = createBreadcrumbs();
const crumbs = $derived(await getBreadcrumbs());
</script>
<ol class="breadcrumb-list">
{#each crumbs as crumb}
<li>
{#if crumb.icon}
{@const Icon = crumb.icon}
<Icon />
{/if}
<a href={crumb.url}>{crumb.label}</a>
</li>
{/each}
</ol>
createBreadcrumbs()Creates a reactive breadcrumb resolver. Returns a getter function () => Promise<Breadcrumb[]>.
Call createBreadcrumbs() once to set up the reactive state, then use the returned getter inside $derived(await ...) to get breadcrumbs that update on navigation and resolve during SSR.
// What you export from +page.svelte
type BreadcrumbMeta = BreadcrumbResolver | { routes: Record<string, BreadcrumbResolver> };
// Resolver function
type BreadcrumbResolver = (page: Page) => Promise<BreadcrumbData | undefined>;
// Data for one breadcrumb
type BreadcrumbData = { label: string; icon?: Component<any> };
// Resolved breadcrumb with URL
type Breadcrumb = BreadcrumbData & { url: string };
buildBreadcrumbMap() — manually build the route-to-resolver mapfilePathToRoute(filePath) — convert glob file path to routematchDynamicRoute(map, route) — match a concrete path against dynamic patternsgetResolversForRoute(map, route) — collect resolvers for a given route pathimport.meta.glob eagerly imports all +page.svelte files at build timebreadcrumb export is collected into a Map<route, resolver>(app) are stripped from paths/) resolver is checked first, then each segment is walked from left to right with dynamic [param] matchingawait ensures breadcrumbs are rendered in the initial HTML$derived re-evaluates when the route changescompilerOptions.experimental.async: true — uses $derived(await ...) for reactive, SSR-safe breadcrumbs$app/state and import.meta.glob(group)) are stripped from pathsMIT