Skip to Content
FluxStore is currently invite-only. Some sections of this documentation are still being written and expanded.
CustomizationCustom Templates

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 slotStorefront routeWhat it renders
home/The main storefront homepage
package/packages/[id]Individual package detail pages
gift-cards/gift-cardsGift card purchase page
checkout/checkoutThe cart / payment page
checkout-success/checkout/successPost-purchase confirmation
checkout-failed/checkout/failedPayment failure page
purchase-history/purchase-historyLogged-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

VariableTypeDescription
store.idstringThe store’s UUID
store.namestringDisplay name
store.slugstringURL slug
store.descriptionstringStore description
store.logoUrlstringLogo image URL (may be empty)
store.faviconUrlstringFavicon URL (may be empty)
store.backgroundUrlstringBackground image URL (may be empty)
store.currencystringBase ISO currency code, e.g. USD
store.availableCurrenciesarrayCurrencies the buyer can switch to
store.serversarray{ id, name, isActive } for each linked server
store.seoTitlestringSEO title
store.seoDescriptionstringSEO description

packages

A flat array of every visible package. Each entry has:

FieldTypeDescription
idstringPackage UUID
namestringDisplay name
descriptionstringPackage description (HTML)
pricenumberBase price in the store’s currency
salePricenumber | nullDiscounted price if on sale
imageUrlstringImage URL
categoryIdstringCategory UUID (may be null)
categoryNamestringCategory display name
isSubscriptionbooleanWhether the package is recurring
convertedPricesobject{ 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'.

FieldType
categories[].idstring
categories[].namestring
categories[].displayOrdernumber
categories[].packagesarray

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.

FieldType
cart.itemsarray of { packageId, package, quantity, serverId, customPrice }
cart.countnumber total quantity
cart.totalnumber total in selected currency
cart.currencystring 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

FilterExampleOutput
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

TagOutput
{% 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:

FieldTypeDescription
cart.itemsarrayLine items (see fields below)
cart.totalnumberOrder total in the selected currency
cart.countnumberTotal quantity across all lines
cart.currencystringSelected ISO currency code, e.g. USD
cart.isEmptybooleantrue when the cart has no items
cart.giftCardobject | nilApplied gift card — { amount, scope } — or nil when none

Each entry in cart.items has:

FieldTypeDescription
keystringUnique line key — required by quantity and remove actions
idstringPackage UUID
namestringPackage display name
pricenumberUnit price in the selected currency
lineTotalnumberprice * quantity
quantitynumberCurrent quantity
imagestringPackage image URL (may be empty)
isSubscriptionbooleanWhether this line is a recurring subscription
serverNamestringDisplay 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 valueAdditional attributeWhat it does
cart-close—Closes the drawer
cart-checkout—Navigates to the checkout page
cart-clear—Empties the entire cart
cart-removedata-cart-key="{{ item.key }}"Removes that line from the cart
cart-incdata-cart-key="{{ item.key }}"Increases the line quantity by 1 (subscriptions stay at 1)
cart-decdata-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:

TokenWhat it controls
--flux-cart-bgDrawer panel background
--flux-cart-fgPrimary text colour
--flux-cart-borderDivider and border colour
--flux-cart-accentButton and highlight colour
--flux-cart-accent-fgText colour on top of the accent
--flux-cart-overlayBackdrop 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">&times;</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.