Skip to content

Theme contract — pcreative Commerce

Version 2.0 · replaces 1.0 (themes tied to Next.js/React).

The standard every storefront theme meets so they are interchangeable, customisable from the panel and updatable without losing the customer’s configuration, without giving up each theme having a 100% bespoke design.

Reference implementation: packages/theme-contract. Reference theme: themes/growshop-premium.


1.0 pinned the stack: “Next.js 15 + React 19 + Tailwind 4 + the engine’s SDK”. It worked, but it turned the theme catalogue into “just another React template”, and there it competes with Vercel Commerce, which is free. The backend is headless: what tied it to React was not the system, it was two TypeScript files and a wrapper.

1.0 2.0
theme.config.ts (TypeScript) theme.json + settings.schema.json + settings.json (JSON)
tokens in a TS object tokens.json in W3C/DTCG format
theme-tokens.ts generates CSS by hand the contract generates it, and Style Dictionary compiles it to CSS/SCSS/JS/iOS/Android
lib/commerce.ts wrapping the engine’s SDK data contract over the Store API + per-backend adapter
theme.override.ts (code) theme.override.json (data)
customiser form hardcoded in the admin each theme declares its settings; the panel generates the form

Practical consequence: a theme can be written in Next, Astro, Nuxt, SvelteKit or even PHP, and the same panel installs it, customises it and deploys it. That is what neither Vercel Commerce (a single React storefront) nor WooCommerce/PrestaShop (thousands of themes, but tied to PHP) has.


themes/<id>/
├── theme.json manifest: identity, runtime, what it implements
├── tokens.json design tokens in DTCG format
├── settings.schema.json what can be touched (defines the customiser)
├── settings.json the theme's factory values (the demo)
├── theme.override.json ← written by the panel, PER STORE (not shipped by the theme)
├── preview.png
└── src/ FREE: the theme's code, in whatever stack

The five JSON files are the contract. src/ is the theme’s territory: neither the panel nor the installer look in there.

The rule that holds up everything else: customising never edits the theme, only theme.override.json. That is why updating a theme to a new version does not overwrite the customer’s brand or colours — which is exactly what breaks in any system where “customise” means touching the theme’s own files.


Schema: theme.schema.json.

{
"contract": "2.0",
"id": "growshop-premium",
"name": "Growshop Premium",
"version": "2.0.0",
"runtime": { // ← the field that makes the contract agnostic
"target": "ssr", // ssr | static | spa | php | native
"stack": "next", // OPEN list: astro, nuxt, sveltekit, laravel…
"engine": "node>=20",
"packageManager": "npm",
"commands": { "install": "npm ci", "build": "next build", "start": "next start" },
"env": [
{ "name": "NEXT_PUBLIC_COMMERCE_URL", "required": true },
{ "name": "REVALIDATE_SECRET", "secret": true }
]
},
"capabilities": { // what it implements: the panel warns before activating it
"pages": ["home", "catalog", "product", "cart", "checkout", "..."],
"blocks": ["hero", "featured-categories", "bestsellers"],
"features": ["age-gate", "blog", "reviews", "search"],
"locales": ["es"],
"a11y": "wcag-aa"
},
"commerce": { "contract": "1.0", "adapters": [] }
}

Deployment guesses nothing: the commands and environment variables are declared by the theme. An Astro theme sets "stack": "astro" and "build": "astro build" and the panel does not need to know what Astro is.


3. tokens.json — design in a standard format

Section titled “3. tokens.json — design in a standard format”

Format: Design Tokens Format Module (DTCG), the W3C Community Group standard that reached stability in 2025 and is already supported by Figma, Style Dictionary, Tokens Studio, Penpot and Terrazzo. There is nothing to invent here: a designer exports from Figma and the theme eats it.

{
"color": {
"$type": "color",
"brand": {
"primary": {
"$value": { "colorSpace": "srgb", "components": [0.0863, 0.6392, 0.2902], "hex": "#16a34a" }
}
},
"scale": {
"leaf-400": {
"$value": "{color.brand.primary}",
"$extensions": {
"dev.pcreative.derive": { "from": "{color.brand.primary}", "lighten": 0.24 },
"dev.pcreative.css": "--color-leaf-400"
}
}
}
}
}

Three of our own extensions, all inside $extensions as the spec requires:

Extension For
dev.pcreative.derive Derives one colour from another by lightening/darkening it. It is what lets changing one colour in the panel repaint the whole scale: a CSS variable does not know how to recalculate itself.
dev.pcreative.css Fixes the name of the emitted variable. Useful for adopting the contract in an existing theme without touching a single component.
dev.pcreative.private The token exists but is not emitted as a CSS variable.

Variable names are derived from the path: color.brand.primary--color-brand-primary (identical to Style Dictionary’s name/kebab, so the build and the customiser do not contradict each other).

  • Live (customiser): the contract’s tokensToCss() — no dependencies, runs in Node, on the edge and in the browser. The panel saves the override, the storefront injects the CSS and the site repaints with no rebuild.
  • On build (distribution): a Style Dictionary v5 preset, which understands DTCG out of the box — colour-as-object included — and outputs CSS, SCSS, JS and, if it is ever needed, iOS and Android.

4. settings.schema.json — the customiser, declared by the theme

Section titled “4. settings.schema.json — the customiser, declared by the theme”

Schema: settings.schema.json. The approach is Shopify’s settings_schema.json, with the types aligned to the tokens.

{
"groups": [
{
"id": "design", "label": "Design", "icon": "palette",
"settings": [
{ "type": "color", "id": "primary", "label": "Primary colour",
"token": "color.brand.primary" }, // ← the design ↔ form bridge
{ "type": "select", "id": "mode", "label": "Mode", "default": "dark",
"options": [{ "value": "dark", "label": "Dark" }] }
]
},
{
"id": "commerce", "label": "Store",
"settings": [
{ "type": "number", "id": "codSurcharge", "label": "Cash-on-delivery surcharge", "unit": "€",
"visibleIf": { "setting": "commerce.paymentMethods", "equals": "cod" } }
]
}
],
"blocks": [
{ "type": "hero", "label": "Hero", "limit": 1,
"settings": [{ "type": "text", "id": "heading", "label": "Heading", "required": true }] }
]
}

Field types: text, textarea, richtext, number, range, checkbox, select, radio, color, font, image, url, email, tel, list, token, plus header and paragraph for laying out the form.

A field with token is the only bridge between the form and the design: when the customer changes it, the value goes to the token, from there to the CSS variable, and from there to everything derived from it. That is why the panel does not need to know anything about the theme’s stack to recolour it.


Standard routes (capabilities.pages), so the catalogue is predictable: home · catalog · category · product · search · cart · checkout · order-confirmation · account · legal · about · contact · faq · blog · blog-post · not-found.

Blocks are the composable sections. The theme declares which ones it can render; the override says in what order they go and with what settings:

{ "sections": { "home": [
{ "type": "hero", "settings": { "heading": "Indoor growing, done right" } },
{ "type": "bestsellers", "settings": { "limit": 8 } }
]}}

6. theme.override.json — the store’s layer

Section titled “6. theme.override.json — the store’s layer”

Schema: override.schema.json.

{
"contract": "2.0",
"theme": "growshop-premium",
"themeVersion": "2.0.0",
"settings": { "brand": { "name": "Green Room" }, "design": { "primary": "#16a34a" } },
"tokens": { "color.surface.base": "#0d0f0e" },
"sections": { "home": [ /* … */ ] }
}

It stores only what differs. The merge order is schema defaults ← the theme’s settings.json ← the store’s override, and the merge is per field, not per group: a partial override does not wipe the rest.


theme.json declares commerce.contract, not an SDK. A theme does not import the backend’s SDK: it consumes the data contract and the adapter resolves it. That way the same theme serves the current engine today and another backend tomorrow, and — the other way round — the backend is not married to React themes.

See packages/commerce-contract.


Terminal window
pcc-theme validate themes/growshop-premium # shape (JSON Schema) + coherence between files
pcc-theme css themes/growshop-premium # CSS variables already resolved
pcc-theme info themes/growshop-premium # theme summary

validate does two passes. The shape pass validates each file against its JSON Schema. The coherence pass is the one that catches what no schema sees: a colour field pointing at a token that does not exist, a block the customiser offers and the theme does not implement, an override from a different version, a colour whose hex and components are not the same colour.


  1. theme.meta.json + the identity part of theme.config.tstheme.json.
  2. tokens from theme.config.tstokens.json. To avoid touching components, each token carries dev.pcreative.css with the variable name they already used.
  3. The scales theme-tokens.ts computed in TypeScript → tokens with dev.pcreative.derive. Same function, but declared.
  4. The rest of theme.config.tssettings.schema.json (the fields) and settings.json (the values).
  5. theme.override.tstheme.override.json.
  6. lib/commerce.ts → data-contract adapter.
  7. pcc-theme validate until it comes out clean.

growshop-premium is migrated and serves as a template.