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.
| Fact | Detail |
|---|---|
| Stack | Next.js (static + on-demand fallback) + Storyblok CMS |
| Design system | kickstartDS as a workspace package |
| Theming | `token-theme` stories compiled to CSS at build time |
| Started | 2026, rebuilt from an earlier theme and page repo |
What's in it
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.
Page, section and every component are described in JSON Schema; the Storyblok component configuration, TypeScript types and presets are generated from those descriptions.
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
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.
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.
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.
| Detail | Value |
|---|---|
| Routing | catch-all route + folder-index (`a/a` → `/a`) |
| Themes | `settings/themes/*`, injected at build time |
| Editor | Storyblok visual editor with live theme re-resolution |
Technical notes
The decisions behind it
Why use a CMS for a personal site?
Why not copy the components in?
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;.
| Stage | Value |
|---|---|
| Page theme | the story's theme field, else the global one from settings |
| Theme story | settings/themes/<slug> → css + componentCss |
| Injection | themeCss + componentCss + token, in one data-tokens style tag |
| default | resolves 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
{ 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
token/InlineIcon.tsx and token/icon-sprite.html are rebuilt from token/dictionary/icons/{built-in,presets} — 10 plus 28 glyphs.public/blurhashes/, keyed by image URL and read back per page in getStaticProps.@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.
Links
Where to find this one
