npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-splits import() the same way should work.
  • A Wagtail backend exposing pages through its API v2, or anything shaped like it: each page carrying a meta.type string, 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-wagtail

A 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_REGISTRY

Each 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_REGISTRY

Each 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, so keyof typeof and satisfies 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 context StreamFieldOutlet falls back to automatically; PageOutlet already calls this for you. Call it yourself only if you're using the narrower resolver factories directly instead of PageOutlet. See Implicit block context.

Utilities

  • isUnregisteredType(error) — true if error came from a type with no registry entry at all, rather than a loader that ran and rejected. See Failure handling.
  • didStaleChunkRecoveryFail(error) — true if error already 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)