Skip to Content
DevelopersBuildingShopify theme import

12 min read

Shopify theme import and the Liquid engine

Merchants upload the theme zip Shopify emails them (Online Store → Themes → Import Shopify theme). The rule the owner set: copy the look exactly, run every function on our own features, and say in plain words what changed. One owner module does all of it: packages/core/src/theme-import/ (server entry index.ts, browser-safe half @commerce/core/theme-import → client.ts).

Pipeline

StepFileWhat it does
Read the ziparchive.tsReads the zip’s own directory, then inflates each entry with fflate’s streaming Inflate 16 KB at a time and stops as soon as it passes the size the entry declared (a “zip bomb” that says 1 KB and inflates to gigabytes costs one small step, security audit 2026-10-06): 50 MB zip, 120 MB unpacked, 15 MB per file, 4,000 files. Paths with .., absolute paths, drive letters or NUL are dropped, not cleaned. Only theme folders and known file types are kept; everything else is listed as skipped. Nothing is executed.
Storeimport.tsA new unpublished theme “<name> (imported)” (the live theme is never touched). Every file goes to theme_file (text as UTF-8, binary as base64, with a checksum). theme_import keeps the theme’s settings (settings_data.json current), theme_info and the report. Migration 0085_shopify_theme_import, both tables under RLS.
Copy mediaimport-media.tsThe address is the typed one, else the connected Shopify importer store (shopify_import_connection). Before any download, findImportedMedia reuses files the importer already copied: shopify_import_mapping rows of resource media (image CDN address, matched by file name) and file_name (the Shopify file name, written by the importer for videos from now on; re-run the importer once to fill it for older imports). Only what is still missing is downloaded. shopify://shop_images/x → https://<host>/cdn/shop/files/x through @commerce/core/safe-fetch (public addresses only, redirects re-checked, bytes capped); oversized originals are asked for again at 3000, 2000 and 1400 px. Video names are compared loosely (fileNameKey: case, spaces, _/- and percent-encoding ignored), and a hit also brings the poster (file_poster) and the phone-sized mp4 (file_mobile) the importer copied beside it. A Shopify Files video that the importer has not copied has no address derivable from its name, so the live home page is read once and the n-th video setting of a section is matched to the n-th <video> inside #shopify-section-…__<key>; the main file is the <source> without a media query, the phone one the <source media=…>, the poster poster or data-poster. Copies land in storage and Files (media rows). One import downloads at most 300 images and 20 videos in 3 minutes (THEME_MEDIA_LIMITS); the rest is listed in the report as not copied. The live page is read with forward-only string searches (never a pattern that can backtrack over it). The import counts against the store’s heavy('import') budget.
Templatesimport.ts, fields.tstemplates/index.json → our home, sections/header-group.json → header, footer-group.json → footer, and product, collection and page with their alternates (page.about.json → page about, importedPages; a suffix our templates cannot hold is skipped). In those three the theme’s first main section (main-*) becomes our own (MainProduct, MainCollection, MainPage) and any further main section is dropped; a product or collection template without one gets ours first, so the buy box is never missing. On those pages the page context loads Shopify’s product, collection or page global from the path (resourceHandle in page-context.ts), so the theme’s sections around ours (specs, size guide, related products) read it. Each section becomes one Puck item of type LiquidSection: sectionType, sectionKey, _name (the schema name, shown in Customize), its settings at the top level (settingPropKey prefixes reserved ids) and blocks: [{ id, type, disabled, settings }]. Image settings are stored as { url, alt, width, height }, Shopify links as our paths, handles as handles.
Reportimport-report.ts, report.ts, swap.tsEvery section file: where it is placed, what it relies on (swap table), app blocks, main templates, LiquidJS parse errors → imported, replaced, unavailable, unsupported. Plus menus, collections, products and blogs the imported pages name that this store lacks, images and videos copied or not, skipped files. Copy (English and Hindi) lives in apps/admin/src/data/theme-import.ts keyed by reason.

The engine

engine.ts creates one LiquidJS (MIT) instance per theme version: snippets are served from memory (templates option, no file system), ownPropertyOnly, and render limits (LIQUID_LIMITS, applied by renderBounded): renders are synchronous, so while one runs the whole server waits, for every store on it. One render pass may take 2 s of CPU and write 4 M characters; all renders of one page context together (layout, sections, passes, the canvas’s 60-section batch) 6 s; then the rest render as a comment. Parse and memory caps as before. Shopify-only tags:

TagHere
formDraws the theme’s form; posts to /theme-actions/<action> (cart-add, newsletter, contact, localization) or our pages (login, account). Inner form object reads like Shopify’s, posted_successfully? from ?form_posted= after our handler redirects back.
style, stylesheet, javascriptInline <style> / <script> (with the request’s nonce on the storefront).
commerce_script_nonceOurs. bundle.ts (nonceThemeScripts) writes it after every <script in the theme’s source markup (never inside {% %}, {{ }} or {% raw %}), and it prints nonce="…". A <script> that arrives in data (a product title, an entry, a setting) is output, not source, so it gets no nonce and the CSP blocks it.
section, sectionsStatic sections render in place; groups leave markers in the layout the storefront fills with its own header and footer templates.
content_for 'blocks' / 'block'Theme blocks from blocks/*.liquid.
paginateOne page (collections hand themes up to 50 products), with the paginate object.
schema, doc, layoutIgnored at render.

Filters (filters.ts, values in filter-values.ts): t (locale files, plurals, interpolation, visible “Translation missing”), asset_url (versioned /theme-assets/<theme>/<file>?v=<checksum>), image_url / image_tag (sizes kept from the image, so width/height match), img_url, stylesheet_tag, script_tag, preload_tag, money and friends (INR through @commerce/money, or a Shopify money_format when one is configured), placeholder_svg_tag, json (escapes <), font_face / font_url / font_modify (families loaded from Google Fonts by the layout), colour maths (color_lighten, color_mix, color_contrast…), link and URL helpers, time_tag, date with Shopify’s named formats, payment_type_svg_tag (our own simple marks), file_url (our stored URL when the Shopify importer copied the file, matched by loose name; the old store’s address only when not; read only for themes that call it), video_tag and media_tag for videos (every mp4 rendition as a <source>, the smaller one with media="(max-width: 749px)", the poster, autoplay/loop/muted/controls), external_video_tag. A video setting reaches Liquid as a VideoDrop with sources (1080 and, when copied, 720 high; themes like Zero Drift pick by source.height) and preview_image; the Customize field is an object (url, mobileUrl, poster.url). Unknown filters pass their input through.

Objects on our data

drops.ts, resource-drops.ts and page-context.ts map our records onto Shopify’s shapes: shop, settings (typed by settings_schema.json: fonts, colours, colour schemes, images), section / block, product / variant (prices in paise as Shopify’s subunits; ids are whole numbers from the UUID v7, numeric-id.ts, because themes write "id": {{ variant.id }} into JSON; /theme-actions/cart-add turns them back with resolveThemeVariantId), collection(s), all_products, linklists, blogs, pages, cart, routes (our paths), request, localization (India, INR), template. Resources read as their handle when used as a key (collections[section.settings.collection]). Our built-in pre-order shows up as a selling plan group named “Pre-order”, which is how pre-order apps mark products on Shopify. Menu links carry link.object (the collection, product, page or blog of their path). Metafields and metaobjects read our custom data, see below.

Metafields and metaobjects

The Shopify importer copies metafields into our extra fields (product and collection owners) and metaobjects into content types and entries, storing Shopify’s namespace and key (and metaobject types) through keyFromName (lower snake case). custom-drops.ts maps them back the same way, so product.metafields.custom['size-guide'] finds custom.size_guide.

LiquidHere
product.metafields.<ns>.<key>, collection.metafields…A metafield: .value, .type (Shopify’s name, list. for lists), .list?; printed on its own like Shopify prints it (text as text, references as gid://shopify/…, lists and objects as JSON). Other properties read through to the value (feature.image.width).
.value by typeText, number, boolean, date and URL as is; colour → colour object; rich text → HTML; money → { amount, currency } that money formats; rating → { rating, scale_min, scale_max }; measurement → { value, unit } with type weight / dimension / volume; link → { text, url }; file → image (works with image_url) or { url, media_type: 'generic_file' }; product, collection, page, variant, blog post and entry links → the same drops themes get everywhere; group → rows (json); lists → arrays.
metaobjects.<type> and shop.metaobjects.<type>.values (published entries, oldest first, up to 250), .values_count, [handle]; each entry exposes its fields (entry.title.value) and system.id / handle / type.
variant, page, article, blog, shop metafieldsBlank: we keep no extra fields on those owners yet (the importer only counts them).

Only definitions with storefront access, active entries and published targets are read, under the store’s RLS. Loading goes through the request cache: every owner asked for in a pass is one query per owner type (custom-data.ts, loadMetafields), each content type one query, and entry links are followed three levels deep in the same load; files, products, collections, pages, posts and variants they point at are one query per kind.

Synchronous passes

LiquidJS’s async mode creates a promise per value; React’s development server tracks every promise in a Server Component, which made a home page take 20 s. Renders are therefore synchronous: cache.ts answers from memory and notes anything not loaded, the section is rendered again after one batched load, up to five passes (render.ts, renderUntilComplete). Products (all_products[...], product pickers) and collection products asked for in one pass load in one batch (products, collectionsProducts on the data source). A home page is about 1 s in dev and two passes per section.

Cached renders

The storefront keeps every rendered section and the layout parts per store version, and the theme data Liquid reads per store version too: a page whose sections are all cached runs no Liquid at all. A save, a stock change or a theme publish drops them at once (a Postgres trigger and LISTEN). The cart, the clock and the CSP nonce are handled so one render serves everyone it should: see Storefront performance → Caching.

Where it renders

  • Storefront. apps/storefront/src/lib/liquid-theme.ts: when the request’s theme (live, or a verified preview) has a theme_import row, renderTemplate takes every LiquidSection from the render cache or renders the missing ones (renderLiquidSections, one transaction) and the LiquidSection renderer places it inside Shopify’s own wrapper (<div id="shopify-section-template--…__hero" class="shopify-section …">, ids shaped like Shopify’s because themes key CSS on them). The store layout draws the theme’s layout/theme.liquid head (stylesheets, CSS variables), the body markup before the header group and after the footer group (icon sprites, cart drawer, scripts), with the request’s CSP nonce on every <script> the theme’s own source writes (see commerce_script_nonce above). Our own pages (product, cart, search, account) render inside the theme’s header and footer. The checkout (/checkout and everything under it) never does: the layout drops the theme’s shell, header and footer there (liquidLayoutForPath, lib/checkout-path.ts), so the theme’s CSS cannot restyle it and its scripts cannot rewrite it before React hydrates (Zero Drift’s reveal.js wraps every main h1–h3 in a span, which threw hydration error #418). Assets: /theme-assets/<theme>/<file> (route -s/[storeId]/theme-assets/[themeId]/[file]), cached for a year when ?v= matches, CORS-open for the canvas, SVG sandboxed.
  • Customize. themes.import.editor returns schemas, the schema locale, colour schemes and the theme’s head; use-liquid-theme.ts turns each {% schema %} into Puck fields (liquidSectionFields: text, textarea/richtext, number/range, select/radio, checkbox, image, colour, colour scheme, product/collection/blog/page/menu handles) passed through metadata.liquid; LiquidSection.resolveFields picks the section’s own. Blocks are one array field whose item fields depend on the block type (customize-liquid-blocks.ts). The canvas asks themes.import.render for HTML (batched, liquid-canvas.ts) with everything that can run removed by sanitize-html (canvasSafeHtml, @commerce/blocks/canvas-html: scripts, frames, <meta>/<base>, every on* handler however quoted, script URLs) — the canvas shares the admin’s origin. The iframe gets the theme’s head instead of our theme CSS (preview-frame.tsx).

Behaviour swap

The theme’s JavaScript runs on the storefront for its look (sliders, reveals, marquees). Calls it makes to Shopify go to our features instead (apps/storefront/src/lib/liquid-shim.ts, inline before any theme script). This is not an emulation of Shopify’s API; each call becomes one of ours.

ShopifyHere
Add to cart forms, /cart/add(.js)/theme-actions/cart-add → our cart (addLineAction), then the cart page
/cart.js/theme-actions/cart: count and total from our cart
/cart/change.js, /cart/update.js, /cart/clear.js, cart drawer refreshOur cart page
Section rendering (?section_id=, ?sections=)/theme-actions/section: the theme’s own section rendered by our engine with the collection or product of the path (lazy tabs and carousels)
/search/suggestAn empty answer; the search box submits to our search page
/recommendations/products (product_id, limit, section_id)/theme-actions/recommendations: the theme’s own section rendered with recommendations.products filled from relatedProductIds (theme-import/related.ts): products sharing a collection first, then the same product type, then shared tags; never the product itself, only active products with a sellable variant, store-scoped by RLS. The numeric product_id the theme saw maps back to our UUID like variant ids do
Shopify.customerPrivacy (cookie banner)Our consent cookie: the theme’s banner asks when we would, its answer goes to ConsentManager (commerce:consent event) which stores it and loads the allowed tags
Newsletter form (form 'customer')/theme-actions/newsletter → subscribeAction (double opt-in)
Contact and advice forms (form 'contact')The Forms app (packages/plugin-forms). /theme-actions/contact reads contact[...] fields, drops a post whose honeypot field contact_website is filled, rate-limits per IP and store (5 per 10 minutes), saves a contact_message row (RLS table, store from the hostname never the body) and queues contact.message_received in the outbox; the worker emails the store contact address (contact_message template). The page comes back with ?form_posted=contact (form.posted_successfully?) or contact_error (form.errors). Without the app installed the tag draws “This form is not available right now” instead of a form
CheckoutOurs, never themed: no theme CSS, scripts, header or footer on /checkout/*
Customer account formsOur account pages
App blocks (@app)Left out; listed in the report
App data: product.metafields.reviews.rating / .rating_count, shop.metafields.judgeme.* (Judge.me’s reviews_grid, all_reviews_rating, all_reviews_count, trust_badge.verified_reviews_count)The Reviews app (packages/plugin-reviews). The storefront passes reviewsLiquidSource as storeLiquidData’s reviews option when the app is installed: product metafields get our published average (a rating value out of 5) and count (withReviewMetafields, replacing a rating the importer copied when we have reviews of our own; none with “stars on product cards” off), and shop.metafields.judgeme (ShopMetafieldsDrop) returns the newest 25 published reviews (featured first) in Judge.me’s shape, loaded once per request through the data cache. Without the app those keys stay blank, so the sections stay hidden
Metafields, metaobjectsOur extra fields (products, collections) and content types; app namespaces stay blank until an app fills them
Pre-order appsOur pre-order as a “Pre-order” selling plan group

The same table, in code, is swap.ts (SWAP_TABLE), which also detects each row in a section’s Liquid for the report.

Theme scripts wait for React

An imported page is both the theme’s HTML and a React tree (our swapped-in sections, the layout). The theme’s defer, async and module scripts are parked as inert <script type="text/x-theme-deferred"> placeholders when the Liquid output is placed (apps/storefront/src/lib/liquid-defer.ts, for sections and the layout’s head, before-header and after-footer parts) and run by the shim’s window.__commerceBootTheme() once React has hydrated (components/theme/liquid-boot.tsx: after the layout’s effects and the end of parsing), in document order, each external file once. Classic inline scripts (the js class, settings JSON) still run where they are.

Why: a theme script that ran first edited markup React was about to hydrate (Zero Drift’s lens zoom adds a span to .product-gallery__main, a class our own product section shares). React then rebuilt the page on the client, every Liquid section came back through innerHTML, its scripts never ran again and everything the theme had bound was gone (meteors, reveals, rotating words). The shim also replays the common media="print" onload="this.media='all'" stylesheet pattern, since our CSP blocks inline event handlers.

Tests

packages/core/src/theme-import/engine.test.ts (no database) and import.test.ts (database, fake fetch and DNS, MemoryStorageProvider) run on the fixture theme in packages/core/test/theme-import-fixture.ts. No test touches the network or the owner’s theme.

Not yet

  • Product, collection and other resource templates from the theme (they use our pages today).
  • Editing the theme’s own settings (settings_schema.json) in Customize; adding new sections from the theme in the Add panel.
  • A cart drawer that refreshes in place, predictive search results in the theme’s look.
Was this helpful?