Litekart’s storefront is one SvelteKit codebase serving 108 jewelry stores, each with its own colors, type and spacing generated from a single base token set rather than 108 forks. The code below targets Node.js 24.4.0 (opens in a new tab), TypeScript 5.9.3, Svelte 5.56.8, SvelteKit 2.70.1 and Style Dictionary 5.5.0; a different major version of any of them may change flag names or defaults. The token pipeline below is reproducible as written, but the operational specifics of Litekart’s own run — team size, per-store build time and screenshot coverage — remain unverified in this draft and are marked for confirmation rather than estimated.
Why hand-built themes stop scaling past a handful of stores
Forking the storefront for every merchant looks fine for the first three or four stores. Each fork starts as a full copy of the codebase, so a designer can hand-edit colors and spacing without touching anything else. The cost shows up later: every bug fix and every redesign now has to land in every fork, and each fork drifts a little further from the base with every change that doesn’t make it back into all of them.
Litekart needed 108 store themes shipped in 15 days, and the number of developers who hand-built them was zero: the themes were AI-generated against the token architecture described below. At roughly seven stores a day, hand-forking was never realistic — the only way through was to stop treating a theme as a copy of the app and start treating it as data layered on a shared base. That constraint is what makes the approach work for a generator as much as for a person: a model asked to produce one override file of named tokens has a far narrower target than one asked to restyle an application.
The rest of this article builds that layering: a base token set, a small override file per store, a build step that turns tokens into CSS, and a request-time resolver that picks the right file before anything renders.
Define a base token set instead of a base theme file
A base token set is the shared brand system expressed as data — color, type and spacing values with names — not a stylesheet a designer copies and hand-edits. The W3C Design Tokens Format Module 2025.10 (opens in a new tab), published 28 October 2025, standardizes token files as JSON with a required $value and $type on every token, and an optional $description.
The format’s $extends property is what keeps per-store overrides small. Put it on a token group and that group inherits every token from the group it extends, so a store’s file only has to declare the handful of tokens that actually differ — the rest resolve from the base.
Group tokens by role, not by raw value: color.brand.primary, color.surface, typography.heading. A store override then only touches the roles it’s actually changing, and a designer reviewing the diff sees intent, not hex codes.
A typed token-definitions module also benefits from TypeScript 5.9’s stricter --strictInference under --strict (opens in a new tab), which catches a token file missing a required field at build time instead of at render time.
{
"color": {
"brand": {
"primary": { "$value": "#7a1f3d", "$type": "color", "$description": "Primary brand color" },
"secondary": { "$value": "#c9a86a", "$type": "color" }
},
"surface": {
"default": { "$value": "#ffffff", "$type": "color" }
}
},
"typography": {
"heading": {
"fontFamily": { "$value": "'Playfair Display', serif", "$type": "fontFamily" },
"fontWeight": { "$value": "600", "$type": "fontWeight" }
}
}
}{
"color": {
"$extends": "base.color",
"brand": {
"primary": { "$value": "#1f3a5f", "$type": "color" },
"secondary": { "$value": "#d4af37", "$type": "color" }
}
}
}You should now see: a base token file and one per-store override file that lists only the tokens that differ from the base — every other value resolves through $extends.
Generate CSS custom properties from tokens with Style Dictionary
Style Dictionary (opens in a new tab) 5.5.0 is an Apache-2.0 build system that transforms one set of token source files into per-platform outputs — CSS custom properties, iOS, Android, whatever a project needs. It’s the most common tool for generating many themes from one token set instead of hand-writing each theme’s CSS.
Check the format revision before you commit to it. Style Dictionary has had
first-class DTCG support since version 4, but its own DTCG
documentation (opens in a new tab) states that the 2025.10
revision is not yet completely supported. The $value/$type basics this
article uses are covered; anything newer in that revision — $extends, in
particular — should be tested against your installed version before a store
depends on it, rather than assumed from the spec date.
Litekart reports 40 minutes per store. The exact shape of its production pipeline is not published, so the pattern below is structural rather than a transcript of theirs: one Style Dictionary run per store, with a source glob covering the base tokens plus that store’s override file. Read that 40 minutes as the wall-clock cost of getting one store from brief to reviewed, not as the cost of the build step — generating CSS custom properties from a token file is a sub-second operation, so nearly all of it sits in generating and checking the override, not in running Style Dictionary over it.
{
"source": ["tokens/base.json", "tokens/stores/studio-vera.json"],
"platforms": {
"css": {
"transformGroup": "css",
"buildPath": "build/studio-vera/",
"files": [
{
"destination": "theme.css",
"format": "css/variables",
"options": { "selector": "[data-store='studio-vera']" }
}
]
}
}
}Write the loop in JavaScript rather than shell. Style Dictionary 4 and later
ship as ESM only, so a require() call fails outright, and interpolating shell
variables into an inline node -e string breaks on any store ID containing a
quote:
import { readdir } from 'node:fs/promises';
import { basename } from 'node:path';
import StyleDictionary from 'style-dictionary';
const entries = await readdir('tokens/stores');
for (const entry of entries.filter((name) => name.endsWith('.json'))) {
const storeId = basename(entry, '.json');
const sd = new StyleDictionary({
source: ['tokens/base.json', `tokens/stores/${entry}`],
platforms: {
css: {
transformGroup: 'css',
buildPath: `build/${storeId}/`,
files: [
{
destination: 'theme.css',
format: 'css/variables',
options: { selector: `[data-store='${storeId}']` },
},
],
},
},
});
await sd.buildAllPlatforms();
}Two details carry the version difference. new StyleDictionary(config) replaces
the StyleDictionary.extend(config) initialiser used through version 3 —
.extend() still exists in 5.5.0, but only to derive one instance from another,
not to create the first. And buildAllPlatforms() returns a promise now, so
dropping the await exits the loop before the files are written.
node scripts/build-themes.mjs
ls build/Running this once per store in CI, or as a matrix job, is what turns 108 themes into a batch job instead of 108 manual edits.
flowchart LR
A["tokens/base.json"] --> C["Style Dictionary build"]
B["tokens/stores/studio-vera.json"] --> C
C --> D["build/studio-vera/theme.css<br/>(--brand-primary, --brand-secondary, ...)"]
D --> E["Cascade layers: base, theme, store-overrides"]
You should now see: running the build script produces one generated CSS file per store under build/<store-id>/theme.css, each scoped to that store’s [data-store] selector and containing only its resolved custom-property values.
Register tokens with @property so invalid overrides fail loud, not silent
Plain CSS custom properties (opens in a new tab) are untyped. If a store’s override file sets --brand-primary: not-a-color, the browser doesn’t error — it fails to apply the value and falls back to whatever the property inherited or its initial value, with nothing in the console to say so.
The @property at-rule (opens in a new tab), Baseline “newly available” since July 2024, fixes that by registering a custom property with a syntax, an inherits flag and an initial-value — giving the browser something to validate an override against.
Registering a color token as @property --brand-primary with syntax: '<color>' means a non-color string in a store override is rejected outright, and the property falls back to its declared initial-value instead of an arbitrary inherited one. Registered properties are also animatable, which unregistered custom properties are not — relevant if a store theme ever needs a transition on brand color rather than an instant swap.
Note what a browser without @property support does here, because it isn’t a
cosmetic downgrade. The whole guarantee in this section is that a bad override
fails loudly; on an unsupporting browser the registration is ignored and the
token reverts to a plain untyped custom property, so the invalid value fails
silently again — the exact behaviour the section set out to fix. Type safety
is therefore a build-time and modern-browser property, not a universal one. If
your supported matrix reaches back past mid-2024, validate the override files
in CI as well rather than relying on the browser to catch them.
@property --brand-primary {
syntax: '<color>';
inherits: true;
initial-value: #7a1f3d;
}
@property --space-section {
syntax: '<length>';
inherits: true;
initial-value: 4rem;
}You should now see: opening DevTools’ computed styles panel on a store with an invalid override value shows --brand-primary resolved to #7a1f3d, the declared initial value, instead of an unstyled or blank element.
Order base, theme and store layers with CSS cascade layers
CSS cascade layers (opens in a new tab) (@layer), supported in all major browsers since 2022, let a stylesheet declare an explicit precedence order — @layer base, theme, store-overrides; — where a later layer wins outright over an earlier one, regardless of selector specificity.
Without that explicit order, precedence falls back to specificity and source order, and a bundler that reorders CSS imports during an unrelated refactor can silently flip which rule wins. That’s how a store override “stops applying” with no code change to the override itself.
Scope each store’s override layer to a selector such as [data-store='studio-vera'] so one build’s CSS can ship every store’s rules together, and the browser applies only the block matching the current request.
The layer order is declared once, at the top of the stylesheet. Every later @layer store-overrides { ... } block appends to that layer rather than redefining precedence, so store CSS can be concatenated in any order without changing the outcome.
@layer base, theme, store-overrides;
@layer base {
:root {
--brand-primary: #7a1f3d;
--brand-secondary: #c9a86a;
--surface-default: #ffffff;
}
}
@layer theme {
.product-card {
background: var(--surface-default);
border-color: var(--brand-secondary);
}
}
@layer store-overrides {
[data-store='studio-vera'] {
--brand-primary: #1f3a5f;
--brand-secondary: #d4af37;
}
}You should now see: changing a base token no longer overrides a store’s customization by accident, and the store override in [data-store='studio-vera'] applies even if its CSS block is loaded before the base block.
Resolve the store and theme in SvelteKit’s handle hook before rendering
SvelteKit’s handle hook (opens in a new tab) in hooks.server.js runs on every request the server receives and exposes event.url, so it can map the requesting hostname to a store ID before a single byte of HTML is sent.
Store the resolved store ID on event.locals so both server load functions and the root layout can read it without re-resolving the hostname on every call. Set the data-theme attribute on the <html> element from the server-rendered layout, not from client-side JavaScript, so the correct cascade layer applies in the first response the browser paints.
This avoids the pitfall where a storefront briefly shows the default brand color and flips to the correct one a moment after the page loads. That flash is a symptom of resolving the theme after hydration; resolving it in handle() removes the step that causes it.
import { STORE_BY_HOSTNAME } from '$lib/server/stores';
export async function handle({ event, resolve }) {
const storeId = STORE_BY_HOSTNAME.get(event.url.hostname) ?? 'default';
event.locals.storeId = storeId;
return resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('data-theme-placeholder', storeId)
});
}export function load({ locals }) {
return { storeId: locals.storeId };
}<script>
export let data;
</script>
<svelte:head>
<link rel="stylesheet" href={`/themes/${data.storeId}/theme.css`} />
</svelte:head>
<slot /><html lang="en" data-theme="data-theme-placeholder">
<head>%sveltekit.head%</head>
<body>%sveltekit.body%</body>
</html>sequenceDiagram
participant Browser
participant Server as SvelteKit server
participant Client as Client JS
rect rgb(245, 235, 235)
Note over Browser,Client: Theme resolved client-side
Browser->>Server: GET / (Host: studio-vera.example)
Server-->>Browser: HTML with default theme
Browser->>Client: hydrate
Client->>Client: read hostname, set data-theme
Client-->>Browser: repaint (visible flash)
end
rect rgb(230, 245, 235)
Note over Browser,Server: Theme resolved in handle()
Browser->>Server: GET / (Host: studio-vera.example)
Server->>Server: handle() reads event.url.hostname, sets locals.storeId
Server-->>Browser: HTML with correct data-theme, no client step needed
end
curl -s https://studio-vera.litekart.test/ | grep -o 'data-theme="[^"]*"'Expected output:
data-theme="studio-vera"You should now see: viewing page source, not just the rendered DOM, shows the correct data-theme attribute already present on <html>, and there’s no visible flash of the wrong theme on load.
Catch a broken theme before it reaches the other 107 stores
Playwright’s toHaveScreenshot() assertion (opens in a new tab) does pixel-diff visual regression against a checked-in baseline image per browser and platform, with an --update-snapshots flag for intentional baseline refreshes.
Playwright’s own docs warn that baselines must be generated in the same environment CI uses — specifically its own Docker image — because OS, fonts and GPU rendering all affect pixel output. Generate a baseline locally on a different OS and CI fails on stores nobody touched, purely from font-rendering differences.
Run one screenshot test per store, or a representative sample, against the pages most likely to break: homepage, product listing, cart. That checks a base-token change against the full catalog rather than just the store the change was made for.
How many of the 108 stores Litekart screenshots per pull request, and how long the full suite takes, are not published either — the pattern below is what such a suite looks like structurally, one test iterating the store list.
import { test, expect } from '@playwright/test';
import { STORE_IDS } from '../src/lib/server/stores';
for (const storeId of STORE_IDS) {
test(`${storeId} homepage matches baseline`, async ({ page }) => {
await page.goto(`https://${storeId}.litekart.test/`);
await expect(page).toHaveScreenshot(`${storeId}-home.png`);
});
test(`${storeId} cart matches baseline`, async ({ page }) => {
await page.goto(`https://${storeId}.litekart.test/cart`);
await expect(page).toHaveScreenshot(`${storeId}-cart.png`);
});
}name: visual-regression
on: pull_request
jobs:
screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test tests/theme-regression.spec.tsnpx playwright test tests/theme-regression.spec.tsYou should now see: a pull request that changes a base token and breaks one store’s cart layout fails CI with a pixel-diff image showing exactly what changed, before it merges.
Validate that every per-store override file is complete before shipping
The failure mode here isn’t an error — it’s silence. A new store’s homepage looks correct because a designer styled the hero and the nav, but the product-listing or cart components render unstyled, or inherit a previous store’s colors, because the override file never set the tokens those components consume.
Generate the list of required token names from the base token set itself — every $value key present in base.json — and diff each store’s override file against it as a build or CI step, not as a design review.
A missing token isn’t automatically a bug: most stores intentionally inherit most base tokens, and an override file that only sets six of forty tokens is the whole point of $extends. The check that matters is for unresolvable references and typos in token names, not simply a shorter file than the base.
Whether Litekart runs this as a standalone script, a CI check or a design QA gate — and what it has caught in practice — is not something they have published.
import { readFileSync, readdirSync } from 'node:fs';
function flattenKeys(obj, prefix = []) {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('$')) continue;
const path = [...prefix, key];
if (value && typeof value === 'object' && '$value' in value) {
keys.push(path.join('.'));
} else if (value && typeof value === 'object') {
keys.push(...flattenKeys(value, path));
}
}
return keys;
}
const base = JSON.parse(readFileSync('tokens/base.json', 'utf8'));
const requiredKeys = new Set(flattenKeys(base));
for (const file of readdirSync('tokens/stores')) {
const store = JSON.parse(readFileSync(`tokens/stores/${file}`, 'utf8'));
const storeKeys = new Set(flattenKeys(store));
const unresolved = [...storeKeys].filter((key) => !requiredKeys.has(key));
if (unresolved.length > 0) {
console.error(`${file}: unresolved token reference(s): ${unresolved.join(', ')}`);
process.exitCode = 1;
}
}node scripts/validate-tokens.mjsYou should now see: adding a new store and misnaming one of its cart-related tokens fails the build with a named list of unresolved tokens, instead of shipping an unstyled cart page.
What this architecture does not solve
Token overrides change color, type and spacing values — not layout. A store that needs a genuinely different page structure, not just different values on the same structure, still needs its own component or template. A token file can’t give a store a different grid or a different checkout flow.
This architecture also assumes every store shares one component library and one build. It doesn’t cover a store that needs a different framework, a separate deployment, or a fully separate codebase — that’s a different problem with a different answer.
Cascade layers and @property are broadly supported in current browsers, but a project still targeting older browser baselines needs to verify support before relying on either for anything load-bearing, rather than assuming Baseline status covers every visitor.
What this write-up does not test: whether the approach holds past 108 stores, and what happens under a redesign that renames tokens rather than just changing their values — both are open questions past the scale described here.
flowchart TD
Q{"Does the store need a different value,<br/>or a different structure?"}
Q -->|"Different color, spacing, type value"| A["Token override file"]
Q -->|"Different layout, component, page flow"| B["New component or template"]
A --> C["Covered by this architecture"]
B --> D["Out of scope: separate build work"]