This guide is for two readers: a shop owner, and the AI assistant they paste it into (Claude, ChatGPT, Gemini, Grok, or any other). Everything a theme can use is listed here. Nothing else exists, so do not invent variables.
What a theme is
A folder, zipped for upload, containing:
theme.json name, version, author, description
templates/ Jinja2 templates (list below)
assets/ CSS, images, fonts - served at /theme/<file>
theme.json:
{ "name": "Sea Glass", "version": "1.0.0", "author": "Your name", "description": "Calm and coastal." }
Templates use plain Jinja2 in a sandbox. Any template you leave out falls
back to the built-in Default theme's version, so a theme can start as one file
(layout.html plus assets/theme.css) and grow.
Templates: layout.html, home.html, product.html, collection.html,
collections.html, search.html, cart.html, checkout.html, order.html,
page.html, blog.html, post.html, faq.html, 404.html.
Optional pages a theme may override (the Default theme's versions are used
when a theme leaves them out): privacy.html, terms.html (the shop's legal
pages, same variables as page.html), and my_orders.html (the buyer's own
orders, see below).
Every page template should start with {% extends "layout.html" %} and fill
{% block content %}. Link the stylesheet with
<link rel="stylesheet" href="{{ urls.theme_asset }}theme.css">.
Rules a theme must follow
- Show
product.shipping.textandshop.tax_noteon the product page. Hiding delivery cost is the biggest reason people abandon a basket; the platform rejects a theme that leaves them out. - Keep the platform's forms as they are: the add-to-basket form posts to
{{ urls.cart }}/add, the checkout form posts to itself. Every POST form needs<input type="hidden" name="csrf_token" value="{{ csrf_token }}">. - Work without JavaScript. Add JavaScript for polish if you like.
- Load nothing from other sites unless the shop owner asked for it. Fonts and
images belong in
assets/. - Print
{{ json_ld|json_ld }}and{{ page.breadcrumbs|breadcrumb_ld }}in<head>so search engines and AI assistants can read the shop. - Use
{{ shop.custom_head|safe }}in<head>so the owner's extra tags work.
Filters
money:{{ 1250|money }}gives£12.50in the shop's currency.json_ld: turns a dict into a<script type="application/ld+json">tag.breadcrumb_ld: turnspage.breadcrumbsinto BreadcrumbList JSON-LD.
Variables on every page
shop
- name, slug, currency, url (https://slug.shopmorphic.com)
- tagline, contact_email, address (multi-line), logo_url (may be empty)
- accent - one of: yellow amber orange deep_orange red pink purple deep_purple
indigo blue light_blue cyan teal green light_green lime brown grey blue_grey
- social.instagram, social.facebook, social.tiktok (URLs or empty)
- nav - list of {title, url} (published collections, then Blog if on)
- blog_enabled, newsletter_enabled, rewards_enabled (booleans)
- shipping_note - the owner's one-line delivery promise
- tax_note - "Prices include VAT." or "VAT at 20% is added at checkout." or empty
- faq_title, custom_head
cart
- count, subtotal (integer minor units), subtotal_text
- lines - list of {key, title, variant_title, qty, unit_text, line_total_text,
answers: [{label, value}], url, in_stock, image: {thumb_url, alt} or null}
- country, country_name - where the visitor asked for delivery (the shop's
own country until they pick one), can_ship (boolean)
- delivery - list of {id, label, price, price_text, days_min, days_max}, the
delivery options for that country, cheapest first; empty when the shop does
not deliver there
currency
- code - the currency prices are showing in, base - the shop's own
- options - list of {code, name} the shop offers, switchable (boolean:
more than one), url - post cur (and optional next) there to switch.
All *_text values and cart amounts are already in currency.code;
bare integer amounts such as product.price stay in the shop's own currency.
page
- title, description, canonical
- breadcrumbs - list of {title, url}, first is Home
urls
- home, cart, checkout, search, collections, faq, blog, contact,
privacy, terms, my_orders, theme_asset. Link privacy, terms and
my_orders from the footer: buyers have a legal right to find them.
platform - {name, url}. now_year. csrf_token. json_ld (dict or none).
Product card (in lists)
products / collection.products / c.products are lists of:
- id, title, slug, url
- price (minor units), price_text, price_from (true when variants differ)
- compare_at_price, compare_at_price_text, on_sale
- in_stock, is_bundle
- image - {url, thumb_url, alt, width, height} or null
Product page
product has everything a card has, plus:
- description_html (safe to print with |safe), sku
- images - list of {url, thumb_url, alt, width, height}
- options - list of {name, values}
- variants - list of {id, title, option_values, price, price_text,
compare_at_price_text, in_stock, sku, image}
- custom_fields - list of {id, label, kind (text|textarea|select), choices,
required, max_length, price_delta, price_delta_text}; post each as cf_<id>
- bundle_items - list of {title, quantity, url}
- shipping - {text, label, price, price_text, free_over, free_over_text,
days_min, days_max, country, country_name} or null; the estimate is for
the country remembered from the basket page (text says where)
- seo_title, seo_description
- collections - list of {title, url}
The add-to-basket form posts product_id, variant_id (when there are
variants), qty, and cf_<id> fields.
Collections
collections - list of {id, title, slug, url, description_html, products}.
collection on a collection page has the same keys.
Search
query (string) and products.
Basket page
Uses cart and countries (list of (code, name)). Quantity form posts key
and qty to {{ urls.cart }}/update; a button named action with value
remove removes the line. A form posting country to {{ urls.cart }}/country
remembers the delivery country; show cart.delivery so the cost is known
before checkout.
Checkout page
form- the values typed so far:email, name, line1, line2, city, postcode, country, shipping, discount, notes, newslettercountries- list of(code, name)shipping_options- list of{id, label, price_text, days_min, days_max}totals-{subtotal_text, discount, discount_text, discount_label, shipping_text, shipping_label, tax, tax_label, tax_text, total_text}gateways-{stripe: bool, paypal: bool, square: bool, mollie: bool, klarna: bool, gocardless: bool}gateway_choices- the ones switched on, in order, as{id, name, button}(buttonis the plain wording for the button, e.g. "Pay with PayPal")error- message or nonepoints_available(minor units),points_available_text- reward points and credit this buyer can spend now;points_lockedis true when the typed email has points but this browser has not yet proved it owns that address. Show a button that posts the form to{{ urls.my_orders }}with a fieldnextset to{{ urls.checkout }}; the emailed link brings them back with points unlocked.
Show one submit button per entry in gateway_choices, named pay with the
entry's id as its value. A button named apply re-prices with the discount
code. A checkbox use_points spends points.
Order page
order - {number, status (pending|paid|cancelled|refunded), name, email,
lines (as cart lines), subtotal_text, discount, discount_text, shipping_text,
tax, tax_label, tax_text, total_text, address (multi-line), points}
My orders page (my_orders.html, optional)
Buyers have no accounts. They type their email, get a one-time link, and the link signs this browser in for that address.
buyer-{email}; empty email means not signed in: show the email form (postsemail, andnextifform.nextis set) to{{ urls.my_orders }}.orders- list of order dicts as on the order page, plusplaced_text,status_text,fulfilment_text,tracking,total_text,url.sent- true after the link was emailed.error- message or none.asked- true just after a removal request.request_open- true while one is waiting for the shop.- Signed in, link to
{{ urls.my_orders }}/export.json(their data as a file), post to{{ urls.my_orders }}/eraseto ask for removal, and post to{{ urls.my_orders }}/sign-out.
Legal pages (privacy.html, terms.html, optional)
content - {title, body_html}, the shop's privacy policy or terms of sale,
which the owner edits in the dashboard.
Blog, posts, pages, FAQ
posts- list of{title, slug, url, summary, published_text, image_url}post-{title, body_html, summary, published_text, published_iso, image_url}content(page) -{title, body_html}faqs- list of{question, answer_html, answer_text}
Uploading
Zip the folder, then in the dashboard go to Design and upload it. The platform checks the manifest, renders every template with sample data, and rejects the upload with a plain message if something is missing.