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 languageLiquid — Reference - Liquid
Sellable unitThe variant, never the product
Max variants2,048 per product, 3 options max
RoutesFixed. You cannot invent one
CheckoutNot editable. checkout.liquid is dead
Checkout testsImpossible client-side. Plus only, via extensions
Checkout trackingWeb Pixels only, sandboxed, no DOM
Primary APIGraphQL Admin. REST is legacy
API versionsQuarterly, YYYY-MM, 12-month support
Server-side logicShopify Functions — Rust or JS → WASM
The Plus lineIn-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.price is 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 %}
  • presets is 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, so section.settings.featured_product.title works directly
  • settings_data.json holds 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:

SurfacePlusNon-Plus
Information · Shipping · PaymentEnded 13 Aug 2024Ended 13 Aug 2024
Thank you + Order statusEnded 28 Aug 202526 Aug 2026
script_tags on those pagesEnded 28 Aug 202526 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:

ExtensionPlans
Information / Shipping / Payment stepsPlus only
Thank you + Order status pagesAll 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

APIForAuthNotes
Admin GraphQLEverything back-officeAccess tokenThe only one being invested in
Admin RESTSame, legacyAccess tokenLegacy since 1 Oct 2024
StorefrontHeadless front endsPublic tokenGraphQL, safe to expose
Customer AccountLogged-in customer dataOAuthThe newer accounts surface
AjaxCart, from the themeNoneNo versioning, theme-only
Partner / BillingApp 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.

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