Custom Templates
Custom templates give you full HTML, CSS, JavaScript, and Liquid control over your storefront. They replace the built-in template entirely.
You do not need a custom template to make a beautiful, unique storefront. The built-in templates, Theme Editor, and Layout Editor cover most needs without writing code. Custom templates are for developers who want complete control over the page markup.
Getting started
Open the editor at Dashboard > Customization > Custom Templates. Each template is split into one file per storefront route. Only home is required; the rest fall back to the built-in React rendering when left blank.
| Page slot | Storefront route | What it renders |
|---|---|---|
home | / | The main storefront homepage |
package | /packages/[id] | Individual package detail pages |
gift-cards | /gift-cards | Gift card purchase page |
checkout | /checkout | The cart / payment page |
checkout-success | /checkout/success | Post-purchase confirmation |
checkout-failed | /checkout/failed | Payment failure page |
purchase-history | /purchase-history | Logged-in customer’s order + subscription list. Best used as a chrome wrapper around {% module my_purchases %} |
custom-page | /[pageSlug] (e.g. /about, /faq) | One template, applied to every store-created custom page. Acts as the chrome around the page’s rich-text body |
Live React widgets (recent payments, donation goal, live server status, etc.) don’t get page slots because they aren’t routes - they’re embedded within pages via {% module name %} (see below).
Click Preview on Storefront to open the live storefront with ?preview_template=<id> in the URL. While you are signed in to the dashboard, the draft template renders in place of the active one and a yellow “Preview mode” banner appears so you know you are not looking at the live site. Anonymous visitors cannot see drafts.
Templates render client-side with a 3-second timeout. Loops that take longer ({% for i in (1..999999) %} and similar) will abort and show a generic error to the customer. Validate your loops!
Available Liquid variables
The renderer injects the following objects.
store
| Variable | Type | Description |
|---|---|---|
store.id | string | The store’s UUID |
store.name | string | Display name |
store.slug | string | URL slug |
store.description | string | Store description |
store.logoUrl | string | Logo image URL (may be empty) |
store.faviconUrl | string | Favicon URL (may be empty) |
store.backgroundUrl | string | Background image URL (may be empty) |
store.currency | string | Base ISO currency code, e.g. USD |
store.availableCurrencies | array | Currencies the buyer can switch to |
store.servers | array | { id, name, isActive } for each linked server |
store.seoTitle | string | SEO title |
store.seoDescription | string | SEO description |
packages
A flat array of every visible package. Each entry has:
| Field | Type | Description |
|---|---|---|
id | string | Package UUID |
name | string | Display name |
description | string | Package description (HTML) |
price | number | Base price in the store’s currency |
salePrice | number | null | Discounted price if on sale |
imageUrl | string | Image URL |
categoryId | string | Category UUID (may be null) |
categoryName | string | Category display name |
isSubscription | boolean | Whether the package is recurring |
convertedPrices | object | { USD: 9.99, EUR: 9.20, ... } if conversion is enabled |
On the package page (package.liquid), currentPackage holds the single package being viewed.
categories
Packages grouped by categoryName, sorted by display order. Uncategorised packages appear in a final pseudo-category with id: 'uncategorized'.
| Field | Type |
|---|---|
categories[].id | string |
categories[].name | string |
categories[].displayOrder | number |
categories[].packages | array |
featured_packages
A short list (max 6) of packages currently on sale. Falls back to the first 4 packages if no sale items exist, so the variable is always populated.
cart
Live, reactive cart state. Updates when the buyer adds or removes items.
| Field | Type |
|---|---|
cart.items | array of { packageId, package, quantity, serverId, customPrice } |
cart.count | number total quantity |
cart.total | number total in selected currency |
cart.currency | string selected ISO currency |
theme
The parsed theme tokens object from the Theme tab. Color tokens are HSL triples (e.g. '173 80% 40%') ready to drop into hsl(...).
Common keys: primary, primaryForeground, secondary, background, card, text, textMuted, accent, border, headerPrimary, footerPrimary, fontFamily, borderRadius. See the Themes reference for the full list.
Filters
| Filter | Example | Output |
|---|---|---|
money | {{ 19.99 | money: 'USD' }} | $19.99 |
image_url | {{ pkg.imageUrl | image_url: 'large' }} | appends ?w=600 (sizes: small 150, medium 300, large 600, xlarge 1200, or a raw integer) |
asset_url | {{ 'logo.png' | asset_url }} | Looks up an uploaded asset and returns its public CDN URL with a cache-buster. Returns empty for missing assets. See Assets below. |
truncate | {{ pkg.description | truncate: 100 }} | first 100 chars + ... |
default | {{ pkg.description | default: 'No description' }} | the value, or the fallback if empty |
date | {{ '2026-05-17' | date: '%b %d, %Y' }} | May 17, 2026 (supports %Y %m %d %H %M %S %b %B) |
All built-in LiquidJS filters (upcase, downcase, replace, where, sort, etc.) are also available. See the LiquidJS docs .
Custom tags
| Tag | Output |
|---|---|
{% package_card pkg %} | Renders a default package card with image, name, description, price, and an Add to Cart button. Useful as a quick fallback when you want store-managed packages without building the card yourself. |
{% add_to_cart_button pkg.id %} | Renders just the Add to Cart button for a given package ID. |
{% category_nav %} | Renders a flat category nav with ?category=… links. |
{% module name [key:value ...] %} | Mounts a live React module (recent payments, donation goal, live server status, etc) at this position. See below. |
{% module %}
Drop a live React storefront module into a custom template. The module is rendered by the same React code that powers the layout-editor modules, so it auto-updates (recent payments stream in, the donation goal animates, live server status refreshes) without you writing any JavaScript.
{% module recent_payments limit:8 %}
{% module donation_goal %}
{% module live_server_status show_players:true %}
{% module featured_package package_id:"abc-123-def" %}Allowed module names: recent_payments, top_customer, donation_goal, community_goal, gift_cards, live_server_status, featured_package, my_purchases. Unknown names render a visible warning so typos surface immediately rather than silently doing nothing.
Prop values can be string literals ("abc" or 'abc'), numbers (5, 1.25), booleans (true, false), null, or a variable reference (cart.count, store.slug). Unknown props are ignored. Each module also reads the store’s configured defaults for that module type, so anything you don’t pass falls back to whatever you set up in the layout editor.
A page can mount at most 50 modules. Each mount is wrapped in an error boundary so one failing module doesn’t take the rest of the page with it.
Add to cart contract
Buttons that should add a package to the cart need both pieces:
<button class="add-to-cart-btn" data-package-id="{{ pkg.id }}">
Add to Cart
</button>The storefront layout intercepts clicks on any element with class add-to-cart-btn and a data-package-id attribute, looks the package up, adds it to the cart context, and opens the cart drawer. The cart drawer itself is React-rendered outside your template, so the buyer always sees the standard cart UI no matter what your template looks like.
Cart drawer
By default the storefront renders a built-in cart drawer whenever a buyer opens the cart. You can replace it entirely by creating a cart.liquid template in the editor. When a cart.liquid is present the platform uses your markup instead of the built-in drawer, shows and hides it as the cart opens and closes, and re-renders it each time the cart changes. When no cart.liquid exists the built-in drawer is shown, styled by your --flux-cart-* CSS tokens.
The cart object in cart.liquid
Inside cart.liquid the cart variable is the full reactive cart, with richer fields than the summary available on other pages:
| Field | Type | Description |
|---|---|---|
cart.items | array | Line items (see fields below) |
cart.total | number | Order total in the selected currency |
cart.count | number | Total quantity across all lines |
cart.currency | string | Selected ISO currency code, e.g. USD |
cart.isEmpty | boolean | true when the cart has no items |
cart.giftCard | object | nil | Applied gift card — { amount, scope } — or nil when none |
Each entry in cart.items has:
| Field | Type | Description |
|---|---|---|
key | string | Unique line key — required by quantity and remove actions |
id | string | Package UUID |
name | string | Package display name |
price | number | Unit price in the selected currency |
lineTotal | number | price * quantity |
quantity | number | Current quantity |
image | string | Package image URL (may be empty) |
isSubscription | boolean | Whether this line is a recurring subscription |
serverName | string | Display name of the linked server (may be empty) |
Prices are numbers. Format them with the money filter:
{{ item.price | money: cart.currency }}data-action contract
The platform intercepts clicks on any element that carries a data-action attribute and executes the corresponding cart operation. No JavaScript is required in your template.
data-action value | Additional attribute | What it does |
|---|---|---|
cart-close | — | Closes the drawer |
cart-checkout | — | Navigates to the checkout page |
cart-clear | — | Empties the entire cart |
cart-remove | data-cart-key="{{ item.key }}" | Removes that line from the cart |
cart-inc | data-cart-key="{{ item.key }}" | Increases the line quantity by 1 (subscriptions stay at 1) |
cart-dec | data-cart-key="{{ item.key }}" | Decreases the line quantity by 1, minimum 1 (subscriptions stay at 1) |
cart-remove-giftcard | — | Removes the applied gift card |
cart-noop | — | Absorbs the click so it does not bubble to a parent cart-close. Put this on your panel/container element instead of using inline onclick handlers, which are stripped for security. |
--flux-cart-* tokens
When you have not provided a cart.liquid the built-in drawer reads these CSS custom properties from your :root. Set them in your theme-vars block:
| Token | What it controls |
|---|---|
--flux-cart-bg | Drawer panel background |
--flux-cart-fg | Primary text colour |
--flux-cart-border | Divider and border colour |
--flux-cart-accent | Button and highlight colour |
--flux-cart-accent-fg | Text colour on top of the accent |
--flux-cart-overlay | Backdrop overlay colour (semi-transparent) |
Minimal cart.liquid example
<div data-action="cart-close" style="position:fixed;inset:0;background:var(--flux-cart-overlay,rgba(0,0,0,.5));z-index:50;">
<aside data-action="cart-noop" style="position:absolute;right:0;top:0;bottom:0;width:22rem;background:var(--flux-cart-bg,#fff);display:flex;flex-direction:column;">
<header style="padding:1rem;display:flex;justify-content:space-between;align-items:center;">
<strong>Your cart ({{ cart.count }})</strong>
<button data-action="cart-close">×</button>
</header>
<ul style="flex:1;overflow-y:auto;padding:1rem;margin:0;list-style:none;">
{% for item in cart.items %}
<li style="display:flex;gap:.75rem;margin-bottom:1rem;">
{% if item.image %}
<img src="{{ item.image | image_url: 'small' }}" alt="{{ item.name }}" style="width:3rem;height:3rem;object-fit:cover;border-radius:.375rem;" />
{% endif %}
<div style="flex:1;">
<p style="margin:0;">{{ item.name }}</p>
<p style="margin:0;">{{ item.price | money: cart.currency }}</p>
<div>
<button data-action="cart-dec" data-cart-key="{{ item.key }}">-</button>
<span>{{ item.quantity }}</span>
<button data-action="cart-inc" data-cart-key="{{ item.key }}">+</button>
<button data-action="cart-remove" data-cart-key="{{ item.key }}">Remove</button>
</div>
</div>
</li>
{% endfor %}
</ul>
{% unless cart.giftCard == nil %}
<p style="padding:0 1rem;">
Gift card: {{ cart.giftCard.amount | money: cart.currency }}
<button data-action="cart-remove-giftcard">Remove</button>
</p>
{% endunless %}
<footer style="padding:1rem;border-top:1px solid var(--flux-cart-border,#e5e7eb);">
<p>Total: {{ cart.total | money: cart.currency }}</p>
<button data-action="cart-checkout" style="width:100%;padding:.75rem;background:var(--flux-cart-accent,#0f766e);color:var(--flux-cart-accent-fg,#fff);">
Checkout
</button>
</footer>
</aside>
</div>Inline event handlers (onclick="...") are stripped from cart.liquid for security. Use data-action attributes for all cart interactions, and data-action="cart-noop" on inner containers to prevent click-through to a parent cart-close.
What you can put in a template
Custom templates render the entire page, so the sanitiser allows everything a theme author normally needs:
<style>blocks for CSS, including media queries and keyframes<script>for analytics (GA4, Meta Pixel, Plausible), widgets, and custom JS<iframe>for Discord widgets, YouTube, Twitch, and similar embeds<form>for contact forms and custom inputs<link rel="stylesheet">for Google Fonts or a CDN-hosted stylesheet
Still blocked: <object> and <embed> (nothing legitimate needs them).
Anything you embed runs on your own subdomain with your customers’ session cookies in scope. Treat third-party <script src> URLs the way you would on any production site — pin to a known version, use Subresource Integrity (integrity="sha384-...") where possible, and review what you ship.
Example: product listing
<style>
:root {
--primary: hsl({{ theme.primary }});
--bg: hsl({{ theme.background }});
--card: hsl({{ theme.card }});
--text: hsl({{ theme.text }});
--muted: hsl({{ theme.textMuted }});
--border: hsl({{ theme.border }});
}
body {
font-family: {{ theme.fontFamily | default: 'system-ui, sans-serif' }};
background: var(--bg);
color: var(--text);
margin: 0;
}
.package-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: {{ theme.borderRadius | default: '0.75rem' }};
padding: 1.25rem;
}
.package-card h3 { color: var(--text); margin: 0 0 0.5rem; }
.package-card .price { color: var(--primary); font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
</style>
<section style="padding: 2rem;">
<h1>{{ store.name }}</h1>
{% if cart.count > 0 %}
<p>You have {{ cart.count }} item(s) totalling {{ cart.total | money: cart.currency }}.</p>
{% endif %}
{% for category in categories %}
<h2 style="color: hsl({{ theme.primary }});">{{ category.name }}</h2>
<div class="grid">
{% for pkg in category.packages %}
<div class="package-card">
{% if pkg.imageUrl %}
<img src="{{ pkg.imageUrl | image_url: 'medium' }}" alt="{{ pkg.name }}" style="width: 100%; border-radius: 0.5rem;" />
{% endif %}
<h3>{{ pkg.name }}</h3>
{% if pkg.description %}
<p style="color: var(--muted);">{{ pkg.description | truncate: 120 }}</p>
{% endif %}
<span class="price">{{ pkg.price | money: store.currency }}</span>
<button class="add-to-cart-btn" data-package-id="{{ pkg.id }}">
Add to Cart
</button>
</div>
{% endfor %}
</div>
{% endfor %}
</section>Snippets
For anything you’d otherwise paste into two pages, extract it to a snippet and include it with {% render 'name' %}.
Snippets are managed under the Snippets section of the editor (below the page tabs). Each snippet has a name ([a-z0-9_-]{1,64}) and a Liquid body. From any page or another snippet, include it with:
{% render 'product-card' %}
{% render 'product-card', pkg: package, theme: 'compact' %}
{% render 'product-card', pkg: featured_packages[0] %}By default a snippet sees all the same data as the calling page (store, packages, cart, theme, etc). Any extra bindings you pass after the comma get added on top, so {% render 'product-card', pkg: package %} exposes {{ pkg }} inside the snippet.
Limits:
- Max 50 snippets per template.
- Max 64-char snippet name, must match
[a-z0-9_-]+. - Same 200KB per-file size cap as page templates.
{% render %}calls nest no more than 4 levels deep (a snippet that renders itself will short-circuit with a comment).
Missing snippets render an HTML comment (<!-- snippet 'name' not found -->) instead of throwing, so a typo doesn’t blank the page.
Assets
Upload images, fonts, CSS, and JavaScript files alongside your template in the editor’s Assets section. Reference them from any page or snippet with the asset_url filter:
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}" />
<script src="{{ 'theme.js' | asset_url }}" defer></script>
<img src="{{ 'hero-bg.jpg' | asset_url }}" alt="hero" />
<style>
@font-face {
font-family: 'MyFont';
src: url('{{ 'myfont.woff2' | asset_url }}') format('woff2');
}
</style>URLs returned by asset_url are CDN-hosted and include a content-hash cache-buster, so re-uploading a file under the same name invalidates browser caches automatically.
Limits and rules:
- Max 5MB per file.
- Max 100 assets per template.
- Allowed extensions:
png,jpg,jpeg,gif,webp,svg,woff,woff2,ttf,otf,css,js. - Filenames:
[a-zA-Z0-9_-]{1,55}.<ext>. Names are lowercased on upload. - Magic-byte verification on images and fonts; an upload that doesn’t match its declared type is rejected.
- SVGs containing
<script>are rejected. - Re-uploading a file with an existing name replaces the previous version (and busts caches via the hash).
Asset uploads need an existing template, so save the template once before opening the Assets panel.
Theme settings ({% schema %} blocks)
Declare settings inside any template or snippet with a {% schema %}...{% endschema %} block. The block contains a JSON document; the store owner gets a form in the editor’s Theme Settings section, and the resolved values land in Liquid as {{ settings.your_id }}.
{% schema %}
{
"settings": [
{
"id": "hero_bg",
"type": "color",
"label": "Hero background",
"default": "#0d3838"
},
{
"id": "hero_title",
"type": "text",
"label": "Hero title",
"default": "Welcome!"
},
{
"id": "show_recent_payments",
"type": "checkbox",
"label": "Show recent payments",
"default": true
},
{
"id": "package_grid_cols",
"type": "number",
"label": "Grid columns",
"default": 3,
"min": 1,
"max": 6
},
{
"id": "layout",
"type": "select",
"label": "Layout style",
"default": "grid",
"options": [
{ "value": "grid", "label": "Grid" },
{ "value": "list", "label": "List" }
]
}
]
}
{% endschema %}
<h1 style="background: {{ settings.hero_bg }};">{{ settings.hero_title }}</h1>
{% if settings.show_recent_payments %}
{% module recent_payments %}
{% endif %}Supported type values: text, textarea, richtext, color, url, image, number, range, checkbox, boolean, select. Setting ids must match [a-z][a-z0-9_]* and be unique across the whole template (page files + snippets share a namespace).
Schemas are pooled from every .liquid source in the template, so it doesn’t matter which file you declare a setting in. Defaults render immediately; store-owner overrides land in settings after they save the form.
Limits:
- Max 200 setting values per template (combined across all schema blocks).
- String values clamped to 1000 chars.
- Number values coerced via
parseFloat; non-numeric values fall back to the schema default.
What doesn’t work yet
- Server-side rendering. Templates render client-side, so very large templates show a brief loading spinner. SEO meta is still server-side and unaffected.
This is tracked for a future release.