Skip to content

Building a Storefront Theme

Vectis Commerce themes let you completely redesign the customer experience — page templates, layout chrome, and even custom routes — without forking the storefront. Themes are file-based packages compiled at build time, selected per channel in the admin, and rendered with a fallback chain so a theme only needs to override what it changes.

Theming tiers

Tier Mechanism Deploy needed?
Tier 1 — Theme CSS Uploaded CSS file overrides design tokens (/settings/themes) No
Tier 2 — Layout variants Built-in header/footer/product-card variants selected in admin No (switching)
Tier 2.5 — Theme packages (this guide) File-based theme replaces templates, chrome, and routes Yes (adding a theme)
Tier 3 — .vtheme upload (planned) Uploaded archive compiled in a sandbox into the same engine No

Anatomy of a theme

Themes live at storefront/src/themes/<name>/:

storefront/src/themes/aurora/
├── theme.json               # manifest (required)
├── templates/               # page-level components (each is a code-split chunk)
│   ├── layout.svelte        # layout shell: announcement + header + main + footer
│   ├── home.svelte
│   ├── products/
│   │   ├── list.svelte
│   │   └── detail.svelte
│   └── pages/
│       └── lookbook.svelte  # template for a custom route
└── components/              # optional theme-private components

The manifest (theme.json)

{
    "theme_schema_version": 1,
    "name": "aurora",
    "label": "Aurora",
    "version": "1.0.0",
    "description": "A dark, editorial storefront design.",
    "tokenDefaults": {
        "colorPrimary": "#8b5cf6",
        "colorSurface": "#0b1020",
        "fontFamily": "Plus Jakarta Sans"
    },
    "routes": [
        { "path": "lookbook", "template": "pages/lookbook", "title": "Lookbook" }
    ]
}
  • name must match the directory name (enforced by the sync script).
  • tokenDefaults prefill the admin token editor when a merchant clicks "Apply token defaults" — keys match ThemeSettings GraphQL fields (colorPrimary, radius, fontFamily, …).
  • routes declare theme custom routes (see below).

After adding or editing a manifest, run make sync-themes so the backend availableThemes query (and the admin picker) sees it. CI can enforce freshness with make check-themes-sync.

Template resolution

Every themeable route's +page.svelte is a thin dispatcher; the real page lives in a theme template. Resolution happens in the route's universal load via the registry (storefront/src/lib/themes/registry.ts):

  • The active theme name comes from per-channel ThemeSettings.theme_name (already fetched in the root layout, Redis-cached).
  • The registry looks up /src/themes/<active>/templates/<id>.svelte, falling back to themes/default. A theme only ships the templates it overrides.
  • All themes are AOT-compiled by Vite via import.meta.glob — no runtime Svelte compilation (TONIC) — and each template is its own chunk, so shoppers only download the active theme's code.

Template ids

Template id Route
layout Layout shell (all pages)
home /
products/list /products
products/detail /products/[slug]
brands/list, brands/detail /brands, /brands/[slug]
search /search
cart /cart
checkout /checkout
cms/page /[slug] (CMS pages)
auth/login, auth/register /login, /register
compare /compare

Account and affiliate portal pages are shared core and not themeable.

The data contract

Templates are presentation only. Each receives the exact data (and form) the core route's load functions produce — the same Houdini stores, compliance flags, and SEO payloads the default templates use. Load functions, the BFF proxy, Redis caching, and the prices-never-cached rule all stay in core routes. When designing an override, start by copying the default template and reshaping the markup.

Non-negotiables carried by the data contract:

  • Compliance gates (product.compliance — showPrice, canPurchase, hideAddToCartButton, hideFromStorefront) must be honored.
  • Tax display: templates call the display helpers, never tax logic.
  • SEO head (data.seo) should be emitted on product pages.

The layout shell

templates/layout.svelte owns the announcement bar, header, <main> (rendering children), and footer. Compliance surfaces — cookie banner, age gate, accessibility widget, extension script injection — stay in the core root layout and cannot be removed by a theme. The shell receives data, user, mainMenu, footerLinks, logoUrl, locale/currency context, contentBlurred (age-gate state), onLogout, and the children snippet. When a theme composes shared chrome such as FooterExpanded, pass theme-owned brand text, logo alt text, and copyright props so visible merchant templates do not inherit the Vectis Commerce defaults.

Custom routes

Manifest routes are served through the CMS catch-all at /<path>. CMS pages win: if a merchant publishes a CMS page at the same slug, it takes precedence, and the theme route only serves when no published page matches. Theme routes are included in the pages sitemap automatically.

Design tokens and CSS

Themes should express colors through the CSS custom properties (--color-primary, --color-surface, …) so merchant token overrides keep working on top of the theme. Theme-specific styling (gradients, effects) belongs in scoped <style> blocks inside templates. Ship recommended token values as tokenDefaults in the manifest rather than hardcoding hex values in markup (TONIC).

Favicon and PWA icons

The root storefront layout reads favicon / apple-touch URLs from the active theme's tokenDefaults (faviconUrl, favicon32Url, appleTouchIconUrl, themeColor, appName). Defaults fall back to /favicon.svg.

The canonical PWA manifest is always /manifest.webmanifest, served by a theme-aware server route (routes/manifest.webmanifest/+server.ts). That route resolves activeTheme.themeName and returns NXT (or other theme) icons/name so Chrome's install and "Open in app" chip match the storefront — a static Vectis SVG at that path previously left the V icon on already-installed apps. Put icon PNGs under storefront/static/ and declare them in theme.json / lib/server/webmanifest.ts.

After changing PWA icons, uninstall the old Chrome app and reinstall — desktop Chrome does not update installed PWA icons from the manifest (web.dev). Steps: open chrome://apps → right-click the old Vectis/NXT app → Remove from Chrome → hard-refresh the storefront → install again from the address bar. Also clear the site’s service worker if the chip still looks stale: DevTools → Application → Service Workers → Unregister, then reload.

Activating a theme

In the admin at Settings → Themes, edit a theme and select the package under Theme Package, optionally applying its token defaults, then save and activate. Switching themes bumps the channel cache version so no stale-template render survives.

Checklist for a new theme

  1. Create storefront/src/themes/<name>/theme.json (name = directory).
  2. Add templates/ for every page you want to override; everything else falls back to default.
  3. Honor the data contract: compliance gates, SEO head, price display helpers.
  4. Run make sync-themes and commit the regenerated themes_manifest.json.
  5. Run npm run check and npm run build in storefront/.
  6. Verify the default theme's JS payload did not grow (templates are code-split).
  7. Select and activate the package in Settings → Themes.

Reference implementations

  • storefront/src/themes/aurora/ — the engine's proof theme: overrides the layout shell, home, and product detail, ships a /lookbook custom route, and demonstrates fallback (every other page renders the default templates inside the aurora shell).
  • storefront/src/themes/nxt/ — a real merchant theme (VapeNXT): official NXT red-to-pink gradient logo assets (/nxt-logo-gradient.png, /nxt-logo-white.png), square app-icon favicon / PWA set (/nxt-favicon.png, /nxt-favicon-32.png, /nxt-apple-touch-icon.png, /nxt-icon-192.png, /nxt-icon-512.png, /nxt-app-icon.png; manifest via theme-aware /manifest.webmanifest), brand-guide tokens (red #EF4136, pink #E96CA2, deep green #1F5434, warm off-white #F7F5F1), a theme-private header component (components/HeaderNxt.svelte) reproducing the merchant's three-row desktop header layout (account bar, logo + search + cart, category mega-menu bar), and a merchandised home template with curated category tiles. Below the desktop breakpoint, the header intentionally becomes a two-row mobile/tablet layout: menu + centered logo + cart/actions on the first row, then a full-width search row so the autocomplete input is never squeezed into the desktop grid. Shows how a theme composes core components (SearchAutocomplete, MiniCart, MegaMenu) inside custom chrome, using opt-in presentation props such as dropdownClass, fullWidth, tone="nxt", and compactBadge so default theme behavior stays stable.

VapeNXT listing and commerce width

The NXT product listing, cart, checkout, and product detail templates share a 1600px content container with the product-detail page's xl padding. The product listing renders two cards per row on mobile and scales through three, four, five, and six columns on wider screens. Product cards keep image and title as normal links, while the Quick Buy control is a sibling button shown by default on touch-sized layouts and revealed on hover or focus for desktop. Quick Buy opens a listing-scoped dialog that renders the shared VariationGrid with axis autodetection and restock notifications disabled, so buyers can select quantities and bulk add to cart without leaving the category page.

VapeNXT product detail behavior

The NXT product detail override keeps compare UI out of the theme, places VariationGrid immediately after the title, and opts into grid-local restock notification buttons for out-of-stock variants. Bell state is persistent for signed-in users: the grid seeds it from myRestockSubscriptions on mount, and clicking a green (subscribed) bell calls unsubscribeFromRestock directly. Guest email subscriptions can't be looked up or deleted by the API, so their green state lasts only for the session. The page supports normal, expanded (compact), and full-screen grid modes; the expanded modes reduce the product image to a 50x50 thumbnail in the grid header. Full screen is a true viewport overlay (fixed inset-0, z-[90]) with its own scroll container, a Close button, Escape-to-close, and a body scroll lock while open — the normal page stays rendered behind the backdrop so closing preserves scroll position. Inside the overlay the grid's sticky column-header row pins to the top of the overlay's scroll container (top: 0) instead of below the site header. The gallery column intentionally scrolls with the page (not sticky).

The grid renders at full height with no internal vertical scrolling — the page scrolls (merchant preference; an earlier capped-height scroll region was reverted). The column-header row locks below the site header while the grid is in view: the component measures the sticky <header> height at runtime for the top offset, the card uses overflow-clip (not overflow-hidden, which would create a scroll container and kill viewport sticky), and the table wrapper only switches to overflow-x-auto when the table is genuinely wider than the card — in that case horizontal panning wins and the header row scrolls normally. Axis lookups normalize attribute keys (slug nicotine-mg, trait nicotine_mg, and attribute name Nicotine MG all resolve to the same axis) and fall back from variant traits to attributeValues, so imported products whose options live only on attributes still render the two-axis matrix.

The grid toolbar has a grid/list layout toggle (shown only when two varying axes exist, so the matrix is actually offerable): grid renders the two-axis matrix, list renders one row per variant. For signed-in users the choice silently persists via setDisplayPreferences; the saved preference — along with variant-image and hide-out-of-stock defaults — is fetched in the product page's server load (myDisplayPreferences) and passed to VariationGrid as the displayPreferences prop, so SSR honors it. Users manage these defaults at /account/preferences ("Shopping display" section); in-page toolbar toggles for images and out-of-stock remain session-only overrides.

Variant images: the gallery column shows a thumbnail strip under the main image (product image plus each distinct variant image) inside the same 350px maximum width as the image card. The rail uses horizontal overflow and scroll snapping so thumbnails scroll within the gallery instead of widening the page; arrow buttons are reserved for sm and wider viewports when more than four thumbnails exist, while narrow screens rely on native horizontal scrolling. Clicking a thumbnail — or clicking a variant row/cell in the grid via the onVariantSelect callback — swaps the main image. The grid toolbar has a shopper-facing "Show variant images" checkbox (shown only when variants carry images) that adds a per-row image column; it defaults to the admin's variantImageMode setting (per_row on, otherwise off).

Above the grid the page shows a preamble: a clearance notice (amber "Clearance — all sales final" banner) when the product carries the clearance trait (first-class trait row with value yes, or clearance: "yes" in the product traits JSONB), and a customer-facing Product ID in the form <productId>C<accountId> (e.g. 7064C1) for signed-in B2B accounts — guests see just the product id. The cart shows a matching notice when any line's product is clearance, driven by the isClearance field on CartLineType (resolved in VariantService.load_variants_with_products from either trait source), plus a per-line "Clearance — final sale" pill.

Below the gallery and grid, product information renders in tabs — Description (short description + description HTML + the after-description slot), Traits (spec badges from product traits plus first-class productTraits rows), Warnings (compliance product-page message plus product meta entries whose key matches warning/caution/disclaimer/prop65 — other meta keys are internal and never rendered), and Quick Links (the related-products grid). Tabs with no content are hidden. The tab data comes from productTraits and meta fields added to the product-detail query in routes/products/[slug]/+page.server.ts.