STOREFRONT DEVELOPMENT
Build storefront
themes with code.
Create storefronts with HTML, CSS and Nunjucks. Connect your design to real shop data, preview it in the code editor, and publish when it is ready.
01 / YOUR FIRST THEME
From starter to storefront
You need a shop account with permission to manage storefront settings, plus a basic understanding of HTML and CSS. Nunjucks adds variables, loops and reusable templates. You do not need PHP, a local server or an npm build to edit an imported theme.
- Download the starter. The JSON includes a home page, product page, reusable sections, product cards, styles and editable theme settings.
- Import it into your shop. Open Storefront → Builder → Import theme, enter a name, select the JSON and choose Import storefront. Importing creates an inactive draft.
- Open Code. Select your imported storefront and choose Code. Open
sections/hero.njkorassets/theme.cssto make your first change. - Save and check the preview. Use Save draft. Check both Home and Product info. The product preview uses sample data.
- Publish. Choose Validate & publish, then test the real shop and an actual product page.
02 / PROJECT FORMAT
Files that travel together
The import file is one JSON object with three keys: manifest, files and settings. The files object maps bundle-relative paths to text contents. Upload the JSON itself, not a ZIP archive or a standalone template.
{
"manifest": {
"engine": "nunjucks",
"engine_version": "1.0",
"context_version": "1",
"pages": {
"home": "templates/home.njk"
}
},
"files": {
"templates/home.njk": "<main class=\"sx-theme\"><h1>{{ shop.name }}</h1></main>",
"assets/theme.css": ".sx-theme { padding: 32px; }"
},
"settings": {}
}This minimal bundle renders a home page. Use the full starter for a product page and purchase controls.
| Directory | Purpose |
|---|---|
layouts/ | Shared wrappers and Nunjucks blocks. |
templates/ | Page entry points named by the manifest. |
sections/ | Large page areas such as a hero, product grid or footer. |
blocks/ | Reusable section content, with its own properties. |
snippets/ | Smaller pieces such as a single product card. |
assets/ | Text-based CSS or JSON files. |
locales/ | Translation JSON and, for visual designs, Builder data. |
Only .njk, .css and .json files are accepted. Use forward slashes and canonical relative paths. The logical theme.json shown below becomes the import's manifest; it is not an extra entry in files.
03 / CONFIGURATION
Describe pages and editable settings
Set engine to nunjucks, engine_version to 1.0, and context_version to 1. The pages.home entry is required and must point to an existing template. Add pages.product to provide a product-page template.
| Manifest key | Meaning |
|---|---|
id, name | Your theme's identifier and human-readable name. |
pages | Maps home and optionally product to template paths. |
page_settings | Page-scoped values, including the section order used by the starter. |
sections, blocks, snippets | Named definitions with a path and optional settings. |
settings_schema | The merchant-editable theme settings and their defaults. |
Supported setting types
| Type | Value and behavior |
|---|---|
text | Trimmed text, up to 500 characters. Read it with themeProperties.your_id. |
color | A six-digit hex color such as #6E56CF. Invalid input falls back to the default. |
checkbox | A boolean. Use a Nunjucks if condition to show or hide content. |
select | An allowed value from options, where each option has value and label. |
Every field needs an id, type, label and sensible default. The import's top-level settings object supplies the initial values. Unknown keys are ignored and supported values are normalized. In the Code editor, use the theme settings form to change these values.
The starter uses the settings accent, surface and text through the platform's CSS variables --sx-accent, --sx-surface and --sx-text.
04 / COMPOSITION
Compose a page from smaller files
Write HTML fragments rather than a complete document. The platform provides the outer HTML document, metadata, stylesheet loading and storefront behavior. The starter wraps its output in .sx-theme and scopes its own styles under .starter-theme.
{% extends "layouts/base.njk" %}
{% block content %}
{% for section_name in settings.section_order %}
{% render_section section_name %}
{% endfor %}
{% endblock %}render_section, render_block, render_snippet and render_template render a definition in the matching manifest collection. For example, Hero resolves to the path in manifest.sections.Hero.path. Pass named arguments when a snippet needs an item from a loop.
Keep the settings scopes distinct
| Variable | Scope |
|---|---|
themeProperties | Normalized merchant settings from settings_schema, available throughout the render. |
settings | At the page entry point: its page_settings. Inside a rendered section or template: that definition's settings. |
properties | A rendered block's settings. Snippets inherit the surrounding context and receive named arguments. |
{# Inside sections/hero.njk #}
<h1>{{ themeProperties.hero_title }}</h1>
<p>{{ settings.eyebrow }}</p>
{# Inside blocks/product-list.njk #}
{% for product in products %}
{% render_snippet "Product card", product=product, show_stock=properties.show_stock %}
{% else %}
<p>No products are available yet.</p>
{% endfor %}Standard Nunjucks if, for, set, block and literal extends / include paths work. Standard imports must use a quoted path, such as {% include "snippets/footer.njk" %}; dynamic include paths are rejected. Templates can only read files in their own bundle.
05 / DATA REFERENCE
Design around the shop's real data
The theme receives public JSON data. It has no database access or server secrets. Use the supplied URLs and display prices so the theme can be imported into another shop.
| Root | Available data |
|---|---|
shop | name, title, slug, description, subtitle, currency, logo_url, favicon_url, brand_color, url, contact_url, contact_links, support_email, show_rating, terms. Contact fields respect the shop's visibility settings. |
products | The home page's product collection. Each item includes name, URL, image, description, display price and stock information. Use a for loop and include an empty state. |
categories | Home-page categories with name, slug, url, description, image_url, icon and products_count. |
routes | home and terms. Product pages also supply product and checkout. Use the tracking button hook below to open order tracking. |
global.locales | locale, language and defaultLocale. |
global.common.shopInfo | Compatibility aliases for shop, items, categories and statistics. Home statistics include products, sold, reviews and rating. |
Product fields
Both product cards and the product page provide name, description, url, image_url, price_cents, price_display, display_price, display_price_cents, currency, stock, stock_label, in_stock, type_key, type_label, badges and status_label.
Prefer display_price on cards because products can have a starting price. price_cents is an integer in the currency's minor units. An unknown or unlimited stock count may be null; display stock_label instead of inventing a number.
On the product page, the current item is product. The products and categories collections are not a related-product feed there. The page additionally provides:
| Field | Use |
|---|---|
product.description_html, product.terms_html | Sanitized rich text. Output these directly inside a block element. For plain-text card summaries, use description. |
product.images | Image entries with url and alt. |
product.variants | Each variant has index, key, name, description, price_cents, price_display, stock, in_stock, min_quantity, max_quantity, available and unavailable_reason. |
product.checkout_url | The current shop's checkout entry point for this product. |
product.description_tabs | Additional sections with title, content and sanitized content_html. |
product.reviews | Review entries with rating, comment and date. Also available: review_count and review_average. |
addons, upsells | Product collections supplied for the current product page. |
safe to names, descriptions from arbitrary input or JSON attributes. The documented rich-text fields go through the platform's sanitizer and do not need a safe filter.06 / PLATFORM BEHAVIOR
Connect product controls without custom scripts
The live product-page wrapper binds the following hooks. The starter's sections/product-detail.njk contains a complete example. These controls are interactive on the real product page; script execution and form submission are restricted in the editor preview.
| Hook | Behavior |
|---|---|
data-sx-product-form | Marks the product purchase form. The platform sends the visitor to this product's own checkout. |
data-sx-variants | Put this on the form and bind its value to product.variants | dump. Keep normal autoescaping enabled. |
data-sx-variant | A select whose option values are variant indexes, not variant keys or product IDs. |
data-sx-quantity | A number input. The platform applies the selected variant's bounds, with a maximum of 25. |
data-sx-total, data-sx-availability | Optional targets updated with the selected total and availability. |
data-sx-checkout-button | The submit button. The platform disables it when the selection is unavailable. |
input[name="addons[]"] | Checked add-on checkboxes inside the form, with the add-on's integer ID as their value. |
data-sx-product-image, data-sx-gallery-image | A main image and gallery buttons. A gallery button's attribute contains its target image URL. |
data-sx-track-order | A type="button" button opens the platform's order-tracking UI. |
data-sx-theme-toggle | A button uses the storefront's existing color-mode switch behavior. |
A regular link to product.checkout_url is a simpler checkout entry point. It does not carry a selection from your custom form. Use the bound purchase form when you offer variant, quantity or add-on controls.
Theme forms do not keep action or method attributes. The platform controls the destination and recalculates prices, stock and payment rules on the server. Theme data is for display, not an order authority.
07 / DESIGN
Write scoped CSS and use public media
assets/theme.css is loaded on themed pages. If present, assets/product.css is also loaded on product pages. Put your styling there; extra CSS files are not automatically loaded just because they exist in the bundle.
Scope selectors to your own wrapper so they do not affect platform UI. Use responsive layouts, visible focus states, image alternative text and an empty-product state. Keep product names and contact links data-driven.
url(...), @import and executable CSS. Use an img with an HTTPS or shop-relative URL, then style it with object-fit. Binary images, JavaScript and font files are not accepted bundle entries.Use shop.logo_url, product.image_url or an image uploaded to the shop gallery. External images must remain publicly reachable. Exports retain image URLs; they do not embed the image files. HTML inline styles and event handlers are removed, so put visual rules in your CSS file.
Working alongside the visual Builder
Code-based themes do not automatically become fully editable visual elements. Custom source is preserved as custom-code nodes. Editing generated page files can replace visual editing for those areas. Export a saved copy before mixing source edits and visual editing, and avoid manually editing locales/builder.json unless you are intentionally working with a visual Builder export.
08 / TEMPLATE UTILITIES
Filters and helpers
| Expression | Result |
|---|---|
1299 | money('USD') | USD 12.99. The input is cents. This filter uses a fixed two-decimal format. |
value | int, value | float | Numeric conversion; an unparseable value becomes zero. |
product.variants | dump | A JSON string. Autoescape it when inserting it into an HTML attribute. |
'catalog.title' | t | Looks up a dotted translation key in the current locale's JSON file. |
'theme.css' | asset_url | A URL for an existing bundle asset. Returns an empty string if the file is absent. This does not load the asset or bypass HTML/CSS restrictions. |
uid() | A render-local ID such as theme-1. Useful for accessible labels; not a persistent database ID. |
value | default('Fallback'), items | length | Examples of standard Nunjucks filters. Test optional values and empty collections explicitly. |
{# locales/en.json contains {"catalog": {"count": "{count} products"}} #}
<p>{{ 'catalog.count' | t({count: products | length}) }}</p>
{# Money takes cents. Prefer product.display_price for catalog display. #}
<p>{{ 1299 | money('USD') }}</p>Translation filenames match global.locales.locale, for example locales/en.json. The locale is lowercase and may use underscores. A missing key returns the key itself; an English translation file is not an automatic fallback for every other locale.
09 / RELEASE WORKFLOW
Preview, publish and share
Save draft validates the edited file and updates the saved draft. The safe preview uses that draft. It disables scripts and form submission, so a working checkout is verified in the live storefront.
The product preview uses Preview product with sample variants and prices. It does not modify or represent an actual product. Check a real product after publishing, including variant selection, quantity, stock handling and the transition to checkout.
Validate & publish validates and renders the theme, creates an immutable revision and activates it. Published revisions → Make live switches to an older published revision without replacing the current draft.
Export an installable design
Save before using the Builder's export button. The export contains the saved manifest, text files and settings. Another merchant can import it into their own shop and publish it after checking links, media and real products. It does not transfer a shop's products, orders or API credentials.
- Test a populated and empty catalog, long names, missing images and unavailable variants.
- Check Home and Product info on narrow and wide screens.
- Keep image URLs reachable and replace shop-specific text before sharing.
- Use only templates and media you have permission to distribute.
10 / COMPATIBILITY
Rules your theme must follow
| Limit | Current value |
|---|---|
| Files per bundle | 120 |
| One source file | 256 KiB |
| Total source contents | 2 MiB |
| Rendered output | 2 MiB |
| Renderer timeout | 900 ms |
| Import upload | 4 MiB for the JSON upload, in addition to the source limits above. |
Scripts, inline event handlers, JavaScript URLs, function/prototype access and dynamic standard template imports are rejected. The renderer cannot fetch external data or read server files. Custom scripts, iframes and external stylesheet tags do not become executable theme features.
HTML is sanitized after rendering. Use allowed semantic HTML and platform data-sx-* hooks. Inline SVG is not a supported theme markup format; use a public image URL or a supported icon already provided by the storefront.
11 / DEBUGGING
Common mistakes and fixes
| Symptom | What to check |
|---|---|
| Import fails | Use valid JSON with object-shaped manifest/files/settings values, a supported engine version, canonical paths and an existing home template. |
| File not found | Match paths and capitalization exactly. Include the file in the files map; a manifest declaration does not create it. |
| A hero setting is blank | Merchant settings live in themeProperties. Section-local settings live in settings. Check the field ID and schema default. |
| Products disappear on the product page | Use product for the current item. The page is not supplied with the home-page catalog collections. |
| CSS is rejected | Remove @import, url(...) and executable CSS. Move images into HTML elements. |
| A script or style vanishes | The theme is sanitized. Put styles in the CSS assets and use the documented platform behavior hooks. |
| Variant selection does not work | Test on the real product page. Use variant indexes, escaped variants JSON and the complete purchase-form hooks. |
| Saving does not update the shop | Publish the draft and confirm the intended storefront is live. Saving and exporting do not activate it. |
| Adding a new file is not possible in Code | Export, add the path and contents to the JSON files object, update the manifest if needed, then import the new bundle. |
| Custom code is not visually editable | Use Code for that area. Arbitrary source does not automatically map to individual Builder elements. |
12 / COMPLETE EXAMPLE
The starter, file by file
These are the exact sources in the downloadable starter. Expand a file to inspect or copy it. The manifest is shown separately; the downloaded JSON already packages everything in the correct import format.
theme.jsonJSON
{
"id": "developer-starter",
"name": "Developer Starter",
"engine": "nunjucks",
"engine_version": "1.0",
"context_version": "1",
"pages": {
"home": "templates/home.njk",
"product": "templates/product.njk"
},
"page_settings": {
"home": {
"section_order": [
"Header",
"Hero",
"Products",
"Footer"
]
},
"product": {
"section_order": [
"Header",
"Product detail",
"Footer"
]
}
},
"sections": {
"Header": {
"path": "sections/header.njk"
},
"Hero": {
"path": "sections/hero.njk",
"settings": {
"eyebrow": "THE COLLECTION"
}
},
"Products": {
"path": "sections/products.njk",
"settings": {
"block_order": [
"Product list"
]
}
},
"Product detail": {
"path": "sections/product-detail.njk"
},
"Footer": {
"path": "sections/footer.njk"
}
},
"blocks": {
"Product list": {
"path": "blocks/product-list.njk",
"settings": {
"show_stock": true
}
}
},
"snippets": {
"Product card": {
"path": "snippets/product-card.njk"
}
},
"settings_schema": [
{
"id": "accent",
"type": "color",
"label": "Accent color",
"default": "#6E56CF"
},
{
"id": "surface",
"type": "color",
"label": "Background",
"default": "#11121A"
},
{
"id": "text",
"type": "color",
"label": "Text color",
"default": "#F4F4FA"
},
{
"id": "hero_title",
"type": "text",
"label": "Hero title",
"default": "Something worth discovering."
},
{
"id": "hero_copy",
"type": "text",
"label": "Hero description",
"default": "Explore our collection and find your next essential."
},
{
"id": "show_description",
"type": "checkbox",
"label": "Show product summaries",
"default": true
},
{
"id": "card_style",
"type": "select",
"label": "Card style",
"default": "bordered",
"options": [
{
"value": "bordered",
"label": "Bordered"
},
{
"value": "minimal",
"label": "Minimal"
}
]
}
]
}assets/product.cssCSS
.starter-product { padding: 40px 0 64px; }
.starter-product-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 40px; margin: 28px 0; }
.starter-product-image { width: 100%; max-height: 440px; object-fit: contain; border-radius: 12px; }
.starter-purchase { align-self: start; border: 1px solid #ffffff25; border-radius: 12px; padding: 24px; }
.starter-purchase form { display: grid; gap: 18px; margin-bottom: 20px; }
.starter-purchase label { display: grid; gap: 6px; }
.starter-purchase select, .starter-purchase input { max-width: 100%; width: 100%; padding: 10px; color: var(--sx-text); background: var(--sx-surface); border: 1px solid #ffffff45; border-radius: 6px; }
.starter-purchase button:disabled { opacity: .5; cursor: not-allowed; }
.starter-description img { max-width: 100%; height: auto; }
@media (max-width: 700px) { .starter-product-grid { grid-template-columns: 1fr; gap: 24px; } }
assets/theme.cssCSS
.starter-theme { min-height: 100vh; background: var(--sx-surface); color: var(--sx-text); font-family: system-ui, sans-serif; line-height: 1.65; }
.starter-theme * { box-sizing: border-box; }
.starter-theme a { color: inherit; text-decoration: none; }
.starter-theme button, .starter-theme input, .starter-theme select { font: inherit; }
.starter-shell { max-width: 1120px; margin: auto; padding: 0 24px; }
.starter-header, .starter-brand, .starter-header nav, .starter-footer { display: flex; align-items: center; flex-wrap: wrap; gap: 24px; }
.starter-header { justify-content: space-between; padding: 28px 0; border-bottom: 1px solid #ffffff20; }
.starter-brand { font-size: 20px; font-weight: 750; gap: 10px; }
.starter-header nav button { color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
.starter-hero { padding: 88px 0; max-width: 760px; }
.starter-eyebrow { color: var(--sx-accent); font-size: 12px; font-weight: 700; letter-spacing: .15em; }
.starter-hero h1 { font-size: clamp(36px, 6vw, 68px); line-height: 1.08; letter-spacing: -.045em; margin: 16px 0 24px; }
.starter-hero > p:not(.starter-eyebrow) { opacity: .8; max-width: 560px; font-size: 18px; }
.starter-button { display: inline-block; background: var(--sx-accent); color: #fff !important; padding: 12px 22px; border: 0; border-radius: 8px; font-weight: 700; cursor: pointer; }
.starter-products { padding: 16px 0 64px; scroll-margin-top: 24px; }
.starter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.starter-card { padding: 20px; min-width: 0; border-radius: 12px; }
.starter-card--bordered { border: 1px solid #ffffff25; }
.starter-card img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; }
.starter-card h3 { font-size: 18px; margin-bottom: 6px; overflow-wrap: anywhere; }
.starter-card p, .starter-card small { opacity: .75; }
.starter-footer { border-top: 1px solid #ffffff20; padding: 28px 0; font-size: 14px; }
.starter-footer span { margin-right: auto; }
.starter-theme a:focus-visible, .starter-theme button:focus-visible { outline: 3px solid var(--sx-accent); outline-offset: 4px; }
@media (max-width: 760px) { .starter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .starter-grid { grid-template-columns: 1fr; } .starter-shell { padding: 0 18px; } .starter-hero { padding: 52px 0; } .starter-header nav { gap: 16px; font-size: 14px; } }
blocks/product-list.njkNJK
<div class="starter-grid">
{% for product in products %}
{% render_snippet "Product card", product=product, show_stock=properties.show_stock %}
{% else %}
<p>No products are available yet.</p>
{% endfor %}
</div>
layouts/base.njkNJK
<div class="sx-theme starter-theme">
<div class="starter-shell">
{% block content %}{% endblock %}
</div>
</div>
locales/en.jsonJSON
{
"catalog": {
"title": "Explore the collection",
"count": "{count} products"
}
}
sections/footer.njkNJK
<footer class="starter-footer">
<span>{{ shop.name }}</span>
<a href="{{ routes.terms }}">Terms</a>
{% if shop.contact_url %}<a href="{{ shop.contact_url }}">Contact</a>{% endif %}
</footer>
sections/header.njkNJK
<header class="starter-header">
<a class="starter-brand" href="{{ routes.home }}">
{% if shop.logo_url %}<img src="{{ shop.logo_url }}" alt="{{ shop.name }}" width="36" height="36">{% endif %}
<span>{{ shop.name }}</span>
</a>
<nav aria-label="Store navigation">
<a href="{{ routes.home }}#products">Products</a>
{% if shop.contact_url %}<a href="{{ shop.contact_url }}">Contact</a>{% endif %}
<button type="button" data-sx-track-order>Track order</button>
</nav>
</header>
sections/hero.njkNJK
<section class="starter-hero">
<p class="starter-eyebrow">{{ settings.eyebrow }}</p>
<h1>{{ themeProperties.hero_title }}</h1>
<p>{{ themeProperties.hero_copy }}</p>
<a class="starter-button" href="#products">Explore products</a>
</section>
sections/product-detail.njkNJK
<main class="starter-product">
<a href="{{ routes.home }}">Back to {{ shop.name }}</a>
<div class="starter-product-grid">
<div>
{% if product.image_url %}<img class="starter-product-image" src="{{ product.image_url }}" alt="{{ product.name }}" data-sx-product-image>{% endif %}
<h1>{{ product.name }}</h1>
<div class="starter-description">{{ product.description_html | default(product.description) }}</div>
{% for tab in product.description_tabs %}
<section><h2>{{ tab.title }}</h2><div>{{ tab.content_html | default(tab.content) }}</div></section>
{% endfor %}
</div>
<aside class="starter-purchase">
<h2>Make it yours</h2>
<form data-sx-product-form data-sx-variants="{{ product.variants | dump }}">
<label>Option
<select name="variant" data-sx-variant>
{% for variant in product.variants %}
<option value="{{ variant.index }}">{{ variant.name }} - {{ variant.price_display }}</option>
{% endfor %}
</select>
</label>
<label>Quantity <input type="number" name="quantity" value="1" min="1" max="25" data-sx-quantity></label>
<strong data-sx-total>{{ product.display_price }}</strong>
<p data-sx-availability>{{ product.stock_label }}</p>
<button class="starter-button" type="submit" data-sx-checkout-button>Continue to checkout</button>
</form>
<a href="{{ product.checkout_url }}">Open checkout directly</a>
</aside>
</div>
{% if product.terms_html or product.terms %}<section><h2>Terms</h2><div>{{ product.terms_html | default(product.terms) }}</div></section>{% endif %}
{% if product.review_count %}
<section><h2>Customer reviews</h2>
{% for review in product.reviews %}<article><strong>{{ review.rating }} / 5</strong><p>{{ review.comment }}</p></article>{% endfor %}
</section>
{% endif %}
</main>
sections/products.njkNJK
<section id="products" class="starter-products">
<h2>Explore the collection</h2>
{% for block_name in settings.block_order %}
{% render_block block_name %}
{% endfor %}
</section>
snippets/product-card.njkNJK
<article class="starter-card starter-card--{{ themeProperties.card_style }}">
<a href="{{ product.url }}">
{% if product.image_url %}<img src="{{ product.image_url }}" alt="{{ product.name }}" loading="lazy">{% endif %}
<h3>{{ product.name }}</h3>
<strong>{{ product.display_price }}</strong>
</a>
{% if themeProperties.show_description %}<p>{{ product.description }}</p>{% endif %}
{% if show_stock %}<small>{{ product.stock_label }}</small>{% endif %}
</article>
templates/home.njkNJK
{% extends "layouts/base.njk" %}
{% block content %}
{% for section_name in settings.section_order %}
{% render_section section_name %}
{% endfor %}
{% endblock %}
templates/product.njkNJK
{% extends "layouts/base.njk" %}
{% block content %}
{% for section_name in settings.section_order %}
{% render_section section_name %}
{% endfor %}
{% endblock %}
Start with a working theme
Import the starter, change one file, and check the result.