Follow

This site

The site you are reading: Next.js against Storyblok, with the design system carrying the themes and the components

A Next.js site against Storyblok, with kickstartDS consumed as a workspace package rather than copied in.

It exists to be a real consumer of the design system: anything that only works because the site can reach into component internals is treated as a defect in the system.


FactDetail
StackNext.js (static + on-demand fallback) + Storyblok CMS
Design systemkickstartDS as a workspace package
Theming`token-theme` stories compiled to CSS at build time
Started2026, rebuilt from an earlier theme and page repo

What's in it

Themes as CMS content

A theme is a token-theme story: branding tokens plus sparse component-token overrides. The page stores a slug, the build compiles it to CSS — which is how the light theme and Neon Canopy coexist without shipping two component sets.

Content types generated from schemas

Page, section and every component are described in JSON Schema; the Storyblok component configuration, TypeScript types and presets are generated from those descriptions.

Static where possible, dynamic where needed

Pages are prerendered, and a folder-index convention (coding/coding serves /coding) lets the project pages live underneath a section without special-casing routes.

In detail

What is actually inside it

A long list of small readers

Icons, tokens, blur hashes, component snippets — all derived during the build. The price is a slower build; the payoff is nothing to remember to update.

Component tokens, not just colours

Mark patterns, glass panels, section transitions, button sheen, the chromatic aberration on header and footer: all component tokens a theme can set. The system ships the mechanism, the theme decides the look.

Storybook as the reference surface

The same design system runs in Storybook with the same CMS themes applied, so components can be judged in isolation and in their real theme — that is what kept the two surfaces from drifting.

DetailValue
Routingcatch-all route + folder-index (`a/a` → `/a`)
Themes`settings/themes/*`, injected at build time
EditorStoryblok visual editor with live theme re-resolution

Technical notes

The decisions behind it

Why use a CMS for a personal site?
Because the interesting problem is the same one clients have: a content model that has to stay in sync with a component library. Using it here keeps the tooling honest.
Why not copy the components in?
So the site stays a consumer of the system like any other project. Convenience imports would hide exactly the integration problems this site is supposed to surface.

How a theme reaches the page

One slug, resolved twice — at build time and again in the editor

A page stores a theme slug, never CSS. fetchPageProps resolves the settings story first and the page second — a page-level theme wins — then loadThemeCss fetches settings/themes/<slug> and lifts two fields off it: css (the compiled branding tokens) and componentCss (the scoped --dsa-* overrides the Design Tokens Editor writes). Both land on the story content as flat props, and _app.tsx concatenates them with the story's manual token string into a single <style data-tokens> element. The slug default short-circuits to empty strings, because its tokens are already compiled into the base stylesheet.

Reading that off the story object was wrong in exactly one situation — the one that mattered. The Visual Editor bridge (useStoryblokState) replaces the server payload with the draft content the editor pushes, which keeps theme and loses themeCss / componentCss; so editing any field dropped the page back to the global theme until a save reloaded it. useThemeCss now resolves by slug: the server payload seeds a slug → CSS map (page and settings both contribute), a theme switched in the editor is fetched from /api/theme?slug=, and outside preview an unknown slug resolves to nothing rather than firing a request.

That endpoint is deliberately narrow. The slug has to match /^[a-z0-9-]+$/i, the response is Cache-Control: no-store, and it builds a draft Storyblok client only when req.preview is set — so unpublished theme edits show up while editing without the preview token ever reaching the browser. The iframe that renders the result is fenced off as well: the site's CSP ends with frame-ancestors https://app.storyblok.com;.

StageValue
Page themethe story's theme field, else the global one from settings
Theme storysettings/themes/<slug> → css + componentCss
InjectionthemeCss + componentCss + token, in one data-tokens style tag
defaultresolves to nothing — its tokens are already in the base CSS
Editor change/api/theme?slug=, preview only, no-store

What Storyblok sends, what the components see

storyProcessing() rewrites the payload before anything renders

The API answers with nested, type-tagged objects, and none of them is what a component wants. An image arrives as { filename, alt, fieldtype: "asset" }; a link as { url, linktype, cached_url }; a relation as a whole story; a single-blok field as a one-element array; a number as a string. The JSON Schemas describe the component contract, not the delivery format, so without a translation step every component would have to know both.

storyProcessing() walks the story once and rewrites it against the generated component schemas. Asset objects become plain CDN URL strings — except inside seo, where the metadata component needs the object. Multilinks become strings (mailto: for email, "#" when they cannot be resolved). Relations are replaced by their content, a single-blok field is unwrapped out of its array, and a number field gets Number()d. Asset alt text is copied onto the component's own alt or <group>_alt key. unflatten() then folds the flat keys back into objects: image_src (already a plain URL) plus image_alt become image: { src, alt } — which is why the design system can read image.src even though the CMS field was an asset object.

An image field with no file is deleted rather than emptied, so unflatten() builds no image object at all and every consumer has to tolerate that. The site renders the *ContextDefault exports, and unlike the defaulted wrappers those receive these props raw: a business-card without a photograph threw Cannot read properties of undefined (reading 'src') and aborted next build on the first /coding deploy. All four image-reading components now guard the read.

From source to container

What the build derives, and how it ships

38
Sprite symbols
token/InlineIcon.tsx and token/icon-sprite.html are rebuilt from token/dictionary/icons/{built-in,presets} — 10 plus 28 glyphs.
21
Blur hashes
Committed under public/blurhashes/, keyed by image URL and read back per page in getStaticProps.
3
Bundled content behaviours
@kickstartds/content's lazy scripts — count-up, slider, visual — are globbed into the client bundle alongside the design system's.

The build is a fixed chain: build-tokens → sync-default-theme → extract-tokens → blurhashes → bundle-static-assets → next build, then postbuild runs next-sitemap and pagefind. extract-tokens emits the component-token catalog (token/components.js, 754,363 bytes) next to tokens.css (193,891 B); calculateCssProperties.js measures real layout instead of trusting the token file — token/calculated.js records section widths of 613 / 901 / 1298 px for narrow / default / wide.

bundle-static-assets is the one with teeth. It synthesises an esbuild entry from a glob over the design system's global.client.js, every component *.client.js, @kickstartds/content/lib/*/lazy*.js and the site's own client scripts, bundles with splitting + treeShaking into public/_/, and writes the esbuild hash to components/bundle-hash.ts as a cache-buster. Two consequences: the bundle is built, not watched, so next dev keeps serving a stale copy after you edit a client script; and back when the content glob listed only lib/slider/lazySlider.js, every ks-component="content.count-up" element rendered but never initialised — the stat numbers on the homepage, /music and /coding sat at their final value until the glob became lib/*/lazy*.js (+108 bytes on the entry chunk).

Shipping is Kamal, one config per service, and the file has to be named explicitly: Kamal 2 resolves config/deploy.<destination>.yml, so the repo's hyphenated config/deploy-website.yml is only reachable as kamal deploy -c config/deploy-website.yml. The website image does not build the design system — it copies packages/design-system/dist/ in, because injectWorkspacePackages: true snapshots that dist into pnpm's store at install time; a design-system change therefore needs a local DS build before the deploy, or the container keeps serving the previous one. .dockerignore is what keeps the build honest: **/.env.local, **/.env.sh and **/*.pem, after a root-anchored pattern once copied .env.local into the build context and baked NEXT_PUBLIC_SITE_URL=https://localhost:3010 into the image. The output is Next's standalone server on port 3030, health-checked at /api/up, with CACHEBUST as the build arg that forces a fresh layer.