@sinnwerkstatt/svelte-wagtail
v0.2.3
Published
Render Wagtail pages and StreamField blocks in Svelte — code-split, without losing server-rendered content.
Downloads
350
Readme
@sinnwerkstatt/svelte-wagtail
Render Wagtail pages and StreamField blocks in Svelte — code-split, without losing server-rendered content.
See CONTRIBUTING.md for design rationale and internals. See AGENTS.md for agent-specific operating notes. See CHANGELOG.md for release history.
Development uses AI-assisted tooling; every change is reviewed and maintained by a human.
Introduction
Wagtail sends pages and StreamField blocks as JSON, each tagged with a type string that names which component should render it. That string has to become a real component before anything can be rendered — and doing that lazily, inside a component, breaks server rendering, since the lazy-rendering primitive available for it has never rendered its resolved branch during SSR. svelte-wagtail resolves it the other way around: ahead of render time, in a load()-style hook, so a server-rendered page contains actual content instead of a loading placeholder, and rendering itself becomes a matter of handing an already-resolved value to a component.
The library is built around exactly two things: a resolver you build once from your own registries, and two rendering components — one for a page, one for a StreamField — that take whatever that resolver produced. A failed load doesn't crash the page or throw past your control; it shows a small fallback and, for the common case of a stale deployment, recovers on its own. Nothing about any of this is SvelteKit-specific, Wagtail-specific beyond the JSON shape, or opinionated about your own components — you bring your own registries, your own page and block components, and your own styling.
Requirements
- Svelte 5+ with runes (
$props(),$derived, generics on<script>). - A bundler that code-splits dynamic
import()into real, hashed chunks — proven against Vite; any bundler that code-splitsimport()the same way should work. - A Wagtail backend exposing pages through its API v2, or anything shaped like it: each page carrying a
meta.typestring, and StreamField content as an array of{ id, type, value }entries. - Nothing else. No CSS framework, no error-reporting SDK, no app-specific page/block types baked in.
Installation
pnpm add -D @sinnwerkstatt/svelte-wagtailA dev dependency, not a runtime one — like svelte/@sveltejs/kit themselves, it's fully bundled at build time. Everything this package exports — factories, the rendering components, their supporting types, and a couple of small utilities — is importable from the package root; nothing lower-level is reachable separately. See the API section below.
Usage
Four steps: register your pages, register your blocks, build a resolver and use it in load(), then render.
1. Register your pages
// $lib/wagtailRegistries.ts
import { defineRegistry, type PageLoader } from '@sinnwerkstatt/svelte-wagtail'
export const PAGE_REGISTRY = defineRegistry({
'home.HomePage': () => import('$components/pages/HomePage.svelte'),
'home.GenericPage': () => import('$components/pages/GenericPage.svelte'),
// ...
}) satisfies Record<string, PageLoader>
export type PageType = keyof typeof PAGE_REGISTRYEach key is a Wagtail page type's full meta.type string (e.g. "home.HomePage"), not just the model name — Wagtail only guarantees app_label.ModelName is unique, so two apps can define same-named models. Each page component's Props is SvelteWagtailPage<YourPageType> — see step 4.
2. Register your blocks
// $lib/wagtailRegistries.ts (continued)
import type { BlockLoader } from '@sinnwerkstatt/svelte-wagtail'
export const BLOCK_REGISTRY = defineRegistry({
hero_banner: () => import('$components/blocks/HeroBannerBlock.svelte'),
heading: () => import('$components/blocks/HeadingBlock.svelte'),
// ...
}) satisfies Record<string, BlockLoader>
export type BlockType = keyof typeof BLOCK_REGISTRYEach key here is a block's own type string — the same string resolution dispatches on later, wherever that block appears on a page.
Note: every key must be globally unique. Wagtail only scopes a block type name per StreamField/StreamBlock definition, so two unrelated blocks can legally share a name like "quote" — but createResolver's one flat registry can't tell them apart. Deliberate, not an oversight; use separate resolvers per page type (see API) if you genuinely need to reuse a name.
3. Build a resolver once, then resolve in load() — +page.ts, not +page.server.ts
A resolver closes over your registries and whether you're running in the browser, so every route reuses the same, already-configured instance — build it once, in its own module next to your registries rather than inside each load(). Keep it in a separate file from your registries, not colocated: createResolver needs $app/environment's browser, which only resolves inside SvelteKit, while the registries themselves are plain data that other tooling (e.g. build-time chunk verification, see Build-time chunk verification) may need to import standalone, outside any SvelteKit runtime.
// $lib/resolver.ts
import { createResolver } from '@sinnwerkstatt/svelte-wagtail'
import { browser } from '$app/environment'
import { BLOCK_REGISTRY, PAGE_REGISTRY } from './wagtailRegistries'
export const resolve = createResolver({ pages: PAGE_REGISTRY, blocks: BLOCK_REGISTRY }, browser, {
onError: reportLoadFailure, // (kind, type, error) => void
})// +page.ts
import { error } from '@sveltejs/kit'
import { resolve } from '$lib/resolver'
export const load = async ({ fetch, params }) => {
const page = await fetchWagtailPage(fetch, params)
const resolved = await resolve(page)
// A whole-page dispatch failure is a different situation from a single block failing — there's
// no meaningful page left to show around it, so it gets a real HTTP status instead of reaching
// PageOutlet's fallback UI.
if (resolved.page.status === 'error') error(503, 'Page temporarily unavailable')
return { page, resolved }
}page is returned exactly as fetched — resolution never touches it. resolved is kept as one value, never torn apart and reassembled, since it's passed straight through to PageOutlet in step 4 as-is. If you need a +page.server.ts too (e.g. for a server-only auth header), have it return the raw Wagtail JSON only; do the resolve step in the +page.ts that receives that JSON via data.
For a registry that shouldn't get automatic-reload treatment, different settings per registry, or resolving a single block or one StreamField array on its own, see the API section for the narrower resolver factories.
4. Wire it up
<!-- +page.svelte -->
<script lang="ts">
import { PageOutlet } from '@sinnwerkstatt/svelte-wagtail'
let { data } = $props()
</script>
<PageOutlet page={data.page} resolved={data.resolved} /><!-- $components/pages/GenericPage.svelte — one of the components registered in step 1 -->
<script lang="ts">
import { StreamFieldOutlet, type SvelteWagtailPage } from '@sinnwerkstatt/svelte-wagtail'
type Props = SvelteWagtailPage<YourGenericPageType> // however you already type this page
let { page }: Props = $props()
</script>
<StreamFieldOutlet streamField={page.body} /><!-- $components/blocks/HeadingBlock.svelte — one of the components registered in step 2 -->
<script lang="ts">
import type { BlockProps } from '@sinnwerkstatt/svelte-wagtail'
type Props = BlockProps<{ heading: string; text: string }>
let { value }: Props = $props()
</script>SvelteWagtailPage<T> is exactly { page: T } — page is your own type, untouched. StreamFieldOutlet needs streamField={page.body}, the raw block array; it finds its resolved entries automatically, without anything being passed through by hand — see Implicit block context below. id/type are optional on BlockProps, since a block component is sometimes reused directly as a plain sub-component that only ever receives value.
API
Registries
defineRegistry(registry)— an identity function; its only real job is capturing your registry's exact key strings, sokeyof typeofandsatisfies Record<string, Loader>both work at the call site. Returns exactly what you pass in.
Resolving
Four factories build a resolve(...) function once, to be reused across every route, sharing the same (registry, isBrowser, config?) shape:
| Function | Resolves |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createResolver | A whole page, dispatching on page.meta.type, plus every StreamField-shaped field on it — the common case. Takes { pages, blocks } in place of a single registry. |
| createPageResolver | A single page's dispatch on its own, independent of any blocks. |
| createBlockResolver | A single embedded block, independent of any StreamField array. |
| createStreamFieldResolver | One StreamField array's blocks on their own, without resolving the surrounding page. |
registry/registries and isBrowser are required, positional arguments — the actual subject and environment being resolved, never folded into config. isBrowser has no safe default: auto-detecting it would silently report true under a test environment that polyfills a global window without being a real browser, so a test exercising server-side behavior without an explicit override would get the wrong recovery path.
config (optional on every factory — everything in it has a sensible default):
| Option | Type | Default | Purpose |
| ------------ | --------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| onError | see below | falls back to console.error | Purely for reporting — never affects the resolved outcome. See Failure handling. |
| recover | boolean | true | Set false to skip automatic-reload recovery entirely. |
| scope | string | one shared, app-wide scope | Calls sharing a scope coordinate their recovery attempt — see Staleness recovery. Not available on createResolver, which already tracks pages and blocks in independent, fixed scopes automatically. |
| getStorage | () => Pick<Storage, "getItem"\|"setItem"\|"removeItem"> | () => globalThis.sessionStorage | Swap the storage backend — localStorage to share recovery state across tabs, or a fallback where sessionStorage throws (some private-browsing modes). Also a testing seam. |
| reload | () => Promise<never> | reloads the page | Run cleanup before actually reloading (e.g. flush analytics), or substitute a fake in tests. |
onError's exact shape differs slightly by factory: createResolver's is (kind: "page" | "block", type: string, error: Error) => void, tagged since one call covers both registries; the other three take the untagged (type: string, error: Error) => void. A production app should almost always supply a real onError that deduplicates (the same failing type can otherwise fire repeatedly) and caps reporting per session.
Rendering
PageOutlet
| Prop | Type | Default | |
| ------------------ | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| page | T | — | required — raw, exactly as fetched; wrapped as { page } internally when rendering the resolved component |
| resolved | WagtailResolution | — | required — exactly what a resolver returns; resolved.blocks is only used to set up context |
| wrapWithDataAttr | boolean | false | wraps the resolved page in <div data-page={type}>, a devtools aid |
| class | string | "" | applied to the wrapping element |
StreamFieldOutlet
| Prop | Type | Default | |
| ------------------ | -------------------------------- | ------- | ------------------------------------------------------------------------------------ |
| streamField | StreamFieldContent | — | required — the raw block array, e.g. page.body |
| resolved | ResolvedBlocks<BlockComponent> | — | optional — falls back to a provideBlocksLookup context match by block type, if any |
| wrapWithDataAttr | boolean | false | wraps each resolved block in <div data-block={type}>, a devtools aid |
| class | string | "" | applied to the wrapping element |
provideBlocksLookup(getResolvedBlocks)— sets up the contextStreamFieldOutletfalls back to automatically;PageOutletalready calls this for you. Call it yourself only if you're using the narrower resolver factories directly instead ofPageOutlet. See Implicit block context.
Utilities
isUnregisteredType(error)— true iferrorcame from atypewith no registry entry at all, rather than a loader that ran and rejected. See Failure handling.didStaleChunkRecoveryFail(error)— true iferroralready survived one automatic-reload attempt. See Staleness recovery.
Features
Framework integration
The only thing this library needs from its host app is something that resolves data ahead of render time and hands the result to the two rendering components as props — it isn't SvelteKit-specific: @sveltejs/kit is not a dependency of any kind, and nothing under src/lib/ imports it. Concrete options:
- SvelteKit's
load()— the integration this README's examples use throughout. - A plain Vite + Svelte SPA, no SvelteKit — resolve in a router hook, or before mounting.
- Astro's Svelte islands — resolve in Astro's frontmatter, pass the result into a Svelte island as props.
- Direct, isolated rendering — Storybook, a component-explorer, or a unit test, with hand-built resolved props and no surrounding app framework. This is how this package's own test suite exercises its rendering components.
Failure handling
A block or page that fails to resolve — its loader rejected, or its type was never registered — shows a small fallback instead of throwing or crashing the rest of the page, styled through plain CSS custom properties (--svelte-wagtail-fallback-bg, --svelte-wagtail-fallback-font-size). An unregistered type is treated as its own kind of failure, distinguishable via isUnregisteredType, so a fallback can say "Unknown page/block type" rather than "Failed to load" and, in dev mode, show the offending raw value as JSON. Every error's message is dev-mode-only, gated on esm-env's bundler-agnostic DEV rather than a bundler-specific flag — debug information for a developer, never something a site visitor sees.
Staleness recovery
A dynamic import can fail after a redeploy invalidates the hashed chunk URL a client had already cached. The first time that happens for a given scope, the library reloads the page once, on the assumption a fresh load will pick up the new build. If the same scope fails again after that reload, it's treated as a real failure instead of retried forever, and didStaleChunkRecoveryFail lets a fallback say an automatic refresh already ran rather than failing silently again. Pages and blocks resolved through createResolver get independent recovery budgets automatically — a failing block never spends the page's own retry, or vice versa; scope lets the narrower factories opt into the same coordination deliberately instead.
Implicit block context
StreamFieldOutlet's resolved prop is optional because PageOutlet makes resolved.blocks available via Svelte context automatically — every StreamFieldOutlet below it in the tree looks up its own blocks by type, with no setup needed. This removes the need to thread resolved down through components that only exist to wrap a StreamField in some layout — they only need streamField. An explicit resolved prop always takes priority over context when both are present, useful for a component under test or reused in isolation, or one rendered outside PageOutlet entirely. If you're using the narrower resolver factories directly instead of PageOutlet, call provideBlocksLookup(() => blocks) yourself, once, in an ancestor component — as a getter, not a value, so each lookup re-reads the current blocks rather than freezing them as of that component's first render. Omitting resolved with no context available throws a clear error rather than failing silently.
Build-time chunk verification
verifySvelteKitBuildChunks() (@sinnwerkstatt/svelte-wagtail/buildVerification/verifySvelteKitBuildChunks) is a separate, build-time check, not part of the runtime resolution/rendering path: it asserts a production build's client bundle resolves every registry key to a real hashed chunk reference, and that no source-alias path leaked into the bundle instead of one. It's SvelteKit-specific, assuming SvelteKit's own _app/immutable/{chunks,nodes} build-output layout under clientDir (default build/client; pass your own adapter's path via the clientDir option if yours relocates it, e.g. adapter-static). src/routes/ in this repo is a real, runnable reference — built for real in CI, with scripts/verify-build-chunks.ts calling this function against its actual build output; copying that script's pattern into your own SvelteKit app is the intended usage. Such a script typically imports your registries file directly (often via plain tsx, outside any SvelteKit runtime), so that file must not pull in $app/environment — keep resolver construction in its own module instead, as shown in step 3 above.
Registry safety
A registry entry must be a literal import() call, never one built from an interpolated path — a bundler can't always resolve an interpolated specifier to a real chunk at build time, and the failure stays silent until it reaches a real browser. @sinnwerkstatt/svelte-wagtail/eslint-config exports noInterpolatedDynamicImport, a flat-config rule enforcing exactly that in your own registry files:
import { noInterpolatedDynamicImport } from '@sinnwerkstatt/svelte-wagtail/eslint-config'
export default defineConfig(/* ...your config... */, noInterpolatedDynamicImport)