Tags: web-dev commerce platform
Shopify
Checked: 2026-08-16
A hosted, multi-tenant platform where you rent the storefront and Shopify owns the checkout and the data model. Almost every constraint worth knowing follows from that one fact — including every CRO limitation you will hit.
§0 The one idea
You never get a server.
No middleware, no request hooks, no control over response headers, no database. You write templates that Shopify renders, and you extend at points Shopify has chosen to expose. Anything without an extension point is unavailable at any price.
vs a normal stack: there is no app.get('/products/:id'). The route table is fixed, the data model is fixed, and the checkout is not yours. What you get in exchange is that payments, PCI scope, scaling, fraud and tax are someone else’s problem permanently.
YOURS SHOPIFY'S
───── ─────────
theme — Liquid, JS, CSS the checkout
app code on your servers the data model
metafields / metaobjects payments · PCI
Functions (WASM) hosting · CDN
extension components the admin UI
At a glance
The things worth having in your head before opening anything.
| Template language | Liquid — Reference - Liquid |
| Sellable unit | The variant, never the product |
| Max variants | 2,048 per product, 3 options max |
| Routes | Fixed. You cannot invent one |
| Checkout | Not editable. checkout.liquid is dead |
| Checkout tests | Impossible client-side. Plus only, via extensions |
| Checkout tracking | Web Pixels only, sandboxed, no DOM |
| Primary API | GraphQL Admin. REST is legacy |
| API versions | Quarterly, YYYY-MM, 12-month support |
| Server-side logic | Shopify Functions — Rust or JS → WASM |
| The Plus line | In-checkout customisation and B2B |
The five-second version of what Plus buys: checkout UI extensions on the information/shipping/payment steps, Functions at full scope, and B2B. Post-purchase extensions and Functions basics reach everyone.
The data model
Everything else is downstream of this, and the two things that catch people are the variant and the handle.
Shop
├─ Product
│ └─ Variant ─── InventoryItem
│ │ └─ InventoryLevel
│ │ (one per Location)
│ └─ price · sku · 1–3 options
├─ Collection ──< Product
├─ Customer
│ └─ Order
│ ├─ LineItem ──> Variant
│ ├─ Transaction
│ └─ Fulfillment
├─ Metafield (on almost anything)
└─ Metaobject (your own types)
The variant is the sellable unit
The single most common modelling mistake is treating the product as the thing being sold.
PRODUCT what you merchandise
"Gentle Cleanser"
├─ 236ml £9.99 ← the sellable unit
└─ 473ml £14.99 ← the sellable unit
price · sku · barcode · weight · inventory
all live on the VARIANT, never the product
Consequences that bite:
- A product has no price.
product.priceis a convenience for the minimum across variants. Any pricing logic written against products is wrong - A product with one variant still has a variant — the “Default Title” variant. Every product does
- Inventory is a separate object again.
Variant → InventoryItem → InventoryLevel, one level per location. Multi-location makes “is it in stock” a query, not a field — Stockouts and Availability - Analytics item IDs. Whether you send variant ID or product ID to GA4 determines whether size-level analysis is possible at all, and it’s decided once, early, by whoever writes the data layer — Ecommerce Event Schema
Handles
Every object has a handle — the URL-safe slug, and the identifier used everywhere in Liquid.
title "Gentle Cleanser 236ml"
handle gentle-cleanser-236ml
url /products/gentle-cleanser-236ml
rename the title → handle unchanged
change the handle → the old URL 404s
Handles do not follow titles. They’re generated once from the title at creation, then frozen. Merchants rename products constantly and the URL silently keeps the old slug — which is usually what you want, and is occasionally the cause of a URL that no longer resembles the product at all. Changing a handle breaks the URL with no automatic redirect; you create one manually — Redirects and Link Equity.
Metafields and metaobjects
The extension mechanism for the data model, and the difference matters:
METAFIELD an extra field on an existing object
product.metafields.custom.dosage
namespace "custom" · key "dosage"
METAOBJECT a new object type you define
"ingredient" → name · inci · function
product ──< references ──> ingredient
Metaobjects are the under-used one. They give you genuine structured content — ingredient libraries, size guides, practitioner profiles — with references, list fields, and their own storefront routes. Most stores that end up with a separate CMS didn’t need one.
Both are exposed to Liquid, to the Storefront API, and to the admin as native fields. Definitions matter: an undefined metafield still stores data but gets no admin UI, no validation and no API type safety.
The request lifecycle
What actually happens on a page load, and where you can intervene.
GET /products/gentle-cleanser-236ml
│
├─ CDN edge — static asset? → served, done
│
├─ route match
│ ↓
│ templates/product.json
│ ↓
│ sections, in the order listed
│ ↓
│ each renders its Liquid + schema settings
│ ↓
│ layout/theme.liquid wraps the result
│
└─ HTML
no code of yours ran on a server
Liquid runs at request time on Shopify’s infrastructure, with a rendering budget. There is no build step and no incremental static regeneration; caching is Shopify’s business and largely opaque. This is why theme performance work is almost entirely about what you ship to the browser rather than what you render — The Critical Rendering Path, Third-Party Scripts.
The route table
Fixed. Learn it, because “can we have a page at /consultation/step-2” has a specific answer.
/ index
/products/:handle product
/collections/:handle collection
/collections/:c/:tag filtered collection
/pages/:handle page
/blogs/:blog/:handle article
/cart cart
/search search
/account/* customer accounts
/apps/:subpath app proxy → your server
/a/:subpath app proxy (short form)
The app proxy is the escape hatch. It maps a path on the store’s own domain to a request against your server, which renders whatever it likes and can return Liquid for Shopify to process. It’s how you get genuinely custom pages — a quiz, a practitioner portal, a bulk-order form — on the customer’s domain without going headless.
The theme layer
Directory structure
assets/ css · js · images · fonts
config/
settings_schema.json theme settings shape
settings_data.json the merchant's values
layout/
theme.liquid the shell every page uses
password.liquid
locales/
en.default.json translations
sections/ composable, schema-bearing
snippets/ partials, no schema
templates/
product.json which sections, in order
product.bundle.json alternate template
collection.json
index.json
customers/…
blocks/ reusable blocks (newer)
sections/ versus snippets/ is the distinction to hold. A section has a {% schema %} and is merchant-configurable in the theme editor. A snippet is a plain partial, included with {% render %}, invisible to merchants.
JSON templates — the composition model
A template is data, not markup. This is what makes pages merchant-editable without a developer.
// templates/product.json
{
"sections": {
"main": { "type": "main-product" },
"trust": {
"type": "icon-row",
"blocks": {
"b1": {
"type": "icon",
"settings": {
"text": "Free UK delivery over £40"
}
}
},
"block_order": ["b1"]
}
},
"order": ["main", "trust"]
}The order array is the page. Merchants reorder sections in the theme editor and it rewrites this JSON. Which means: a section you built and a section the merchant dragged in are the same thing, and anything you hard-code into theme.liquid is outside their control by design.
Alternate templates — product.bundle.json, page.contact.json — are assigned per object in the admin. This is the supported way to give one product type a different layout, and it’s better than the if product.type == … branching that themes accumulate.
Section schema
The schema is what generates the theme editor UI. No schema, no settings.
{% schema %}
{
"name": "Icon row",
"settings": [
{ "id": "heading",
"type": "text",
"label": "Heading",
"default": "Why buy from us" }
],
"blocks": [
{ "type": "icon",
"name": "Icon",
"limit": 6,
"settings": [
{ "id": "text", "type": "text",
"label": "Text" }
]}
],
"presets": [{ "name": "Icon row" }]
}
{% endschema %}presetsis what makes a section addable from the theme editor. Omit it and the section can only be used in a template you wrote by hand- Settings are typed —
text,richtext,image_picker,product,collection,color,range,select,url. Picker types return the object, not an ID, sosection.settings.featured_product.titleworks directly settings_data.jsonholds the merchant’s values. Editing the schema of a live theme can orphan values that are already set
Theme app extensions
How apps add to a theme without editing theme files. An app ships app blocks (droppable into sections) and app embeds (site-wide, injected into the layout).
This is the distinction that decides whether uninstalling an app leaves debris. Apps using theme app extensions disappear cleanly. Apps that wrote into theme.liquid or a section leave code behind forever, and a theme five years into its life is mostly archaeology of uninstalled apps.
The cart
The Ajax API
Unauthenticated JSON endpoints, callable from the theme. No versioning, no tokens.
POST /cart/add.js add line(s)
POST /cart/change.js qty or properties
POST /cart/update.js attributes · note
GET /cart.js current state
POST /cart/clear.js empty it
GET /cart/shipping_rates.json
One cart per session, held in a cookie. No parallel carts, no saved baskets, no “buy again” cart natively — those are apps, or your own storage keyed to a customer.
Line item properties and cart attributes
The two general-purpose escape hatches for custom data, and both are strings.
LINE ITEM PROPERTY per item
properties[Engraving] "For Sarah"
properties[_bundle_id] "abc123"
↑ leading underscore = hidden
from customer-facing display
CART ATTRIBUTE per cart
attributes[delivery_date] "2026-09-01"
attributes[nhs_number] ← don't
Both flow through to the order and are visible in the admin and the API — which makes them the standard way to carry personalisation, bundle grouping and gift messaging through checkout. They are also a data-protection footgun: anything you put here lands in the order record permanently and is visible to every app with order scope — Consent Management.
The checkout
The single most consequential part of the platform, and the thing to understand before promising anyone a checkout test.
checkout.liquid is dead
Verified timeline:
| Surface | Plus | Non-Plus |
|---|---|---|
| Information · Shipping · Payment | Ended 13 Aug 2024 | Ended 13 Aug 2024 |
| Thank you + Order status | Ended 28 Aug 2025 | 26 Aug 2026 |
script_tags on those pages | Ended 28 Aug 2025 | 26 Aug 2026 |
Non-Plus stores lose scripts on the Thank you and Order status pages on 26 August 2026. Anything still running there — a conversion pixel, an affiliate postback, a review-request script, a survey — stops on that date. It presents as a conversion collapse in whichever tool loses the tag, not as an error.
What replaced it
Checkout Extensibility — declarative, sandboxed, and deliberately narrow:
CHECKOUT UI EXTENSIONS
React-ish components in named targets
▸ purchase.checkout.block.render
▸ purchase.checkout.delivery-address.
render-after
▸ purchase.thank-you.block.render
you pick a TARGET, not a position
SHOPIFY FUNCTIONS
server-side logic, WASM, no UI
BRANDING API
colour · type · corner radius · logo
not layout
Plan availability, verified — this is the split that matters:
| Extension | Plans |
|---|---|
| Information / Shipping / Payment steps | Plus only |
| Thank you + Order status pages | All plans except Starter |
So a non-Plus store can extend post-purchase, and cannot touch the checkout itself.
What this means for CRO
- No client-side testing of checkout. No visual editor reaches it. On Plus, a “test” means shipping two extension configurations and splitting by a Function or an app — Client-Side vs Server-Side Testing
- Cart and pre-cart are still fully yours. Which is where the testable surface actually is, and it’s larger than people assume — Checkout Instrumentation Constraints
- The upside is real. The checkout stopped breaking on theme updates, and it converts well without you. The honest position is that the constraint costs less than it feels like
Shopify Functions
Server-side logic you write, that Shopify runs. The nearest thing to a backend you get.
cart contents
↓
cart.transform merge / bundle lines
↓
cart.lines.discounts line-level discount
↓
order.discounts order-level discount
↓
delivery options filter · rename · sort
↓
payment methods hide · reorder
↓
fulfilment constraints routing rules
↓
ORDER
How one works, and this shape is the thing to remember:
1 you declare a GraphQL query for the input
2 Shopify runs it and passes you JSON
3 your WASM module returns JSON operations
4 Shopify applies them
no network access · no state · no time
pure function, instruction-count limited
Languages, verified: Rust (via the shopify_function crate) and JavaScript (compiled by Javy, which embeds a whole JS engine in the module). Any WASM-targeting language works if it meets the requirements. Rust performs materially better — JavaScript risks the instruction-count limit on large carts or complex logic, which is a real constraint rather than a style preference.
Functions replaced Shopify Scripts, the Plus-only Ruby scripting layer. If you meet a store still on Scripts, that’s a migration waiting to happen.
Where code can run
The complete extension surface, which is the map worth having:
theme Liquid + your JS
theme app extension app blocks / embeds in a theme
checkout UI ext sandboxed components
customer account ext same, on account pages
admin extension UI inside the Shopify admin
Shopify Functions WASM, server-side, no UI
web pixel sandboxed analytics only
app proxy YOUR server, on their domain
Flow (Plus) triggers · conditions · actions
webhooks push to your server on events
Webhooks are the integration backbone — orders/create, products/update, app/uninstalled. They are at-least-once and unordered, so handlers must be idempotent and must not assume sequence. Missing a webhook is normal; Shopify retries, and you reconcile against the API for anything that matters financially.
APIs
| API | For | Auth | Notes |
|---|---|---|---|
| Admin GraphQL | Everything back-office | Access token | The only one being invested in |
| Admin REST | Same, legacy | Access token | Legacy since 1 Oct 2024 |
| Storefront | Headless front ends | Public token | GraphQL, safe to expose |
| Customer Account | Logged-in customer data | OAuth | The newer accounts surface |
| Ajax | Cart, from the theme | None | No versioning, theme-only |
| Partner / Billing | App management | — | App developers only |
New public apps have had to be GraphQL-only since 1 Apr 2025. Existing REST integrations still run; Shopify said migration timelines would be announced. Newer platform features are GraphQL-only regardless, which is the practical forcing function. [CHECK: whether a REST sunset date for existing apps has since been announced.]
Versioning
version names are dates: 2026-04
new stable version: every 3 months
supported for: ≥ 12 months
overlap between versions: ≥ 9 months
unversioned request → oldest supported
retired version → falls FORWARD
Falling forward is the quiet danger. A pinned version that retires does not error — requests are served by a newer version, and behaviour changes underneath an integration that looks healthy. Pin explicitly, and diary the upgrade.
Rate limiting
GraphQL bills a calculated query cost, not requests — a leaky bucket where an expensive query drains more. The general shape is Rate Limiting.
restore rate, verified:
standard 100 points/sec
Advanced 200 points/sec
Plus 1,000 points/sec
single query cap 1,000 points, all plans
↑ checked BEFORE execution, on
requested cost
after execution, the difference between
requested and actual cost is refunded
Practical consequence: a query that could return 250 nodes is charged as if it will, before it runs. Paginating in smaller pages is often faster overall than one large request, which is the opposite of the REST instinct.
REST used a plain request-count bucket. Bulk operations exist for genuinely large reads and writes and sidestep the limit entirely — use them for exports rather than hammering pagination.
Measurement
The pixel sandbox
This is the part most analytics work on Shopify now collides with.
customer action in checkout
↓
Shopify emits a standard event
↓
┌─ strict sandbox — web worker ─┐ app pixels
└─ lax sandbox — iframe ────────┘ custom pixels
sandbox="allow-scripts allow-forms"
↓
your fetch() to your endpoint
↓
GA4 · Meta · warehouse
DOES NOT CROSS THE BOUNDARY
the DOM · window · other scripts
cookies you didn't set here
Verified consequences. Neither sandbox can scrape the DOM for events or metadata, read email or phone from the page, or catch outbound link clicks by DOM. Everything must arrive through the subscribed customer events instead.
Practical effect: the tag you pasted into checkout in 2021 has no equivalent. Vendor pixels either ship a real app pixel or they lose checkout visibility — and a meaningful share of what gets blamed on ad blockers or ITP on Shopify is actually this — Ad Blockers and Tracking Loss, The Data Layer.
Consent
The Customer Privacy API is the consent layer. Pixels declare their consent requirements and Shopify gates them, rather than you writing the gating. Combined with a banner app it’s a workable setup, and it’s one of the few places where the platform’s opinionatedness is straightforwardly good — Consent Management.
Orders and fulfilment
Two independent status fields, which trips people writing reporting:
Order
├─ financial_status
│ pending → authorised → paid
│ → partially_refunded
│ → refunded
│ → voided
└─ fulfillment_status
null → partial → fulfilled
→ restocked
They move independently. A paid, unfulfilled order and an unpaid, fulfilled order are both normal. Revenue reporting that filters on the wrong one is a classic source of a number nobody can reconcile — Revenue Metrics, Tool Discrepancies.
Refunds are objects, not a status change. A partially refunded order retains its original line items and value; the refund sits alongside. Any margin analysis that reads order totals without netting refunds overstates — Return Rate and Reverse Logistics.
International — Markets
Market "Europe"
├─ regions FR · DE · ES · IT
├─ web presence
│ ├─ domain / subdomain / subfolder
│ └─ languages fr · de · es · it
├─ price list % adjustment or explicit
├─ currency + rounding rules
└─ catalogue what's sellable here
Web presence is the SEO decision, and it’s structural: example.fr versus fr.example.com versus example.com/fr. Shopify handles hreflang and locale redirects; the choice of shape is yours and is hard to reverse — Technical SEO.
Managed Markets is the separate, heavier offering where Global-e handles duties, import tax, local payment methods and remittance. Availability is restricted — merchants in the continental US, and certain stores in Canada and the UK. Different product from plain Markets; don’t conflate them.
B2B (Plus)
The reason many trade-heavy retailers end up on Plus at all.
Company
├─ Company location(s)
│ ├─ catalogue what they can buy
│ ├─ price list their pricing
│ ├─ payment terms net 30 etc.
│ └─ tax exemption
└─ Customer(s) ── contacts, with roles
Native B2B means account-specific pricing and catalogues without an app, and it changes the checkout (payment terms, PO numbers). It’s genuinely capable and genuinely rigid — quoting, approval workflows and complex account hierarchies still push people to an app or elsewhere.
Plus versus standard
What the money buys, ignoring the sales framing:
- Checkout UI extensions on the information, shipping and payment steps. The headline
- B2B — companies, catalogues, payment terms
- Shopify Flow — automation
- Higher API rate limits — 1,000 vs 100 points/sec
- More staff accounts, more storefronts (expansion stores)
- Lower fees on third-party payment gateways
- Launchpad — scheduled campaign events
Standard plans get a fully functional store with post-purchase extensions and Functions. The line is in-checkout customisation and B2B, not scale.
Limits worth knowing
- 2,048 variants per product — raised from 100 in late 2025 — but still a hard maximum of 3 options. More than three axes of variation needs an app or theme work, and this is the limit that most often forces a workaround
- 1,000 points maximum cost for a single GraphQL query, all plans
- One cart per session, cookie-scoped
- Line item properties and cart attributes are strings. No types, no validation
- Discount combinability is rule-based — which discounts stack is configured, not free. The usual source of “why didn’t my code apply”
- Theme file count and size limits exist and bite on mature themes
- Checkout UI extension limits were raised to 50 [CHECK: what exactly the 50 counts — extensions per checkout, or targets]
- Metafield definitions have per-object limits; undefined metafields still store data but get no admin UI or validation
Headless
Hydrogen and Oxygen (Shopify’s hosting for it) are the first-party route; anything can use the Storefront API instead. Hydrogen is a React Router 7 framework running on Oxygen, with a framework-agnostic toolkit of the same name in developer preview alongside it — Reference - Hydrogen.
GAINED LOST
────── ────
full control of the the theme editor
front end app blocks entirely
real routing most of the app
your own framework ecosystem
build pipeline merchant autonomy
a lot of time
UNCHANGED
the checkout is still Shopify's
Going headless does not buy you the checkout. It’s the most common misconception about it, and it’s usually the reason the project was proposed — Rendering Strategies, Headless Architecture.
The honest test: headless is right when the front end is genuinely a differentiator or the content model exceeds what themes can carry. It is wrong when the motivation is performance, because a well-built theme with disciplined third-party scripts beats a badly-built headless store comfortably — Core Web Vitals, Third-Party Scripts.
Where it hurts
- App bloat. Every app injects script; ten apps is a performance problem with no owner. The single biggest lever on a typical Shopify store’s Core Web Vitals is uninstalling things — Third-Party Scripts
- No staging that matches production. Theme preview is close. App behaviour, checkout, and Functions are not fully reproducible before release — Preview Environments
- Uninstall debris. Apps predating theme app extensions leave code behind permanently
- Theme updates are a merge, not an update. A customised theme diverges from upstream immediately and stays diverged
- Reporting stops where the checkout starts. Any funnel analysis of checkout steps depends on the pixel events, not on your own instrumentation
- You cannot test the checkout client-side. Worth stating twice, because it’s the constraint most CRO plans are written without
Related
- Reference - Liquid — the template language in depth
- Ecommerce Event Schema · The Data Layer — what to send, and how
- Checkout Instrumentation Constraints — the general problem this platform is the worst case of
- Headless Architecture · Rendering Strategies — the alternative shape