Shopify Theme Developer Interview Crash Course

Everything you need to pass a senior-level Shopify theme developer technical interview. Interactive diagrams, code examples, and practice questions.

Liquid Templating

Objects {{ }}, Tags {% %}, Filters | - the backbone of every Shopify theme

Theme Architecture

Layouts, templates, sections, blocks, snippets - the OS 2.0 way

Online Store 2.0

JSON templates, section groups, app blocks - the modern approach

Performance & A11y

Lighthouse optimization, WCAG 2.0 AA, Web Vitals

Your Advantage You already know Shopify's app ecosystem and Laravel backend. Theme development uses the same concepts (products, collections, metafields) but renders them with Liquid on the server side. Think of Liquid as Blade but for Shopify.

1 How Shopify Renders a Page

Understanding the rendering pipeline is the #1 most important concept. Every interview question traces back to this.

Customer
RequestGET /products/hat
RouterMatches template type
Layouttheme.liquid
Templateproduct.json
Sectionsmain-product.liquid
HTML OutputSent to browser
1
Request ReceivedShopify receives GET /products/hat and identifies this as a product page type
2
Layout Loadedlayout/theme.liquid wraps everything. Contains {{ content_for_header }} (in <head>) and {{ content_for_layout }} (in <body>). These are REQUIRED.
3
Template Resolvedtemplates/product.json is loaded. It defines which sections to render and in what order. The JSON is pure data, no markup.
4
Sections RenderedEach section listed in the JSON template is rendered. Sections live in sections/. Each has a {% schema %} tag defining its settings.
5
Blocks & SnippetsWithin sections, blocks are iterated. Snippets are included via {% render 'name' %}. All Liquid is resolved server-side.
6
Pure HTML SentThe final output is clean HTML/CSS/JS. The customer never sees Liquid code. Everything is server-rendered.

Interview Tip

"Explain the Shopify rendering pipeline" is a guaranteed question. Always mention: Layout wraps Template, Template references Sections, Sections contain Blocks, content_for_header goes in <head>, content_for_layout goes in <body>. All Liquid is server-side rendered - the browser gets pure HTML.

2 Liquid Templating Language

Liquid is Shopify's templating language. Created by Shopify's CEO Tobias Lutke. Written in Ruby, processed server-side. Think of it like Laravel's Blade, but simpler.

The 3 Building Blocks

Objects (Output) {{ }}

Access and display data. Like Blade's {{ $variable }}

{{ product.title }}
{{ shop.name }}
{{ customer.first_name }}
{{ cart.total_price | money }}

Tags (Logic) {% %}

Control flow & logic. Like Blade's @if / @foreach

{% if product.available %}
  <button>Add to Cart</button>
{% else %}
  <span>Sold Out</span>
{% endif %}

Filters |

Transform output. Chainable left-to-right. Like pipes in Unix.

{{ product.title | upcase }}
{{ product.price | money }}
{{ 'cart' | asset_url | script_tag }}
{{ 'now' | date: '%Y' }}

Essential Liquid Objects

product - The Most Important Object

PropertyTypeDescription
product.idIntegerUnique numeric ID
product.titleStringProduct name
product.handleStringURL-safe slug (used in URLs)
product.descriptionStringFull HTML description
product.priceNumberPrice in cents (use | money filter)
product.compare_at_priceNumberOriginal price (for sales)
product.availableBooleanIs any variant in stock?
product.typeStringProduct type
product.vendorStringVendor/brand name
product.tagsArrayArray of tag strings
product.variantsArrayArray of variant objects
product.optionsArrayOption names: Size, Color, etc.
product.options_with_valuesArrayOptions with all values
product.imagesArrayAll product images
product.featured_imageImageFirst/main image
product.selected_variantVariantCurrently selected variant (from URL param)
product.first_available_variantVariantFirst in-stock variant
product.urlStringRelative URL: /products/handle
product.metafieldsObjectAccess metafields: product.metafields.custom.key
product.selected_or_first_available_variantVariantBest variant to show by default
product.has_only_default_variantBooleanTrue if no options configured
product.mediaArrayAll media (images + video + 3D)
<!-- Typical product page pattern -->
<h1>{{ product.title }}</h1>
<p class="price">{{ product.price | money }}</p>

{% if product.compare_at_price > product.price %}
  <s>{{ product.compare_at_price | money }}</s>
  <span class="sale-badge">Sale</span>
{% endif %}

{% for variant in product.variants %}
  <option value="{{ variant.id }}">
    {{ variant.title }} - {{ variant.price | money }}
  </option>
{% endfor %}

collection

PropertyDescription
collection.titleCollection name
collection.handleURL slug
collection.descriptionHTML description
collection.productsPaginated array of products
collection.products_countTotal product count
collection.all_products_countCount including unavailable
collection.imageCollection image
collection.urlRelative URL
collection.sort_optionsAvailable sort options
collection.filtersAvailable storefront filters
collection.current_typeActive product type filter
collection.current_vendorActive vendor filter
{% paginate collection.products by 12 %}
  {% for product in collection.products %}
    <div class="product-card">
      <img src="{{ product.featured_image | image_url: width: 400 }}">
      <h3>{{ product.title }}</h3>
      <p>{{ product.price | money }}</p>
    </div>
  {% endfor %}

  {{ paginate | default_pagination }}
{% endpaginate %}

cart

PropertyDescription
cart.itemsArray of line items
cart.item_countTotal quantity
cart.total_priceTotal in cents
cart.total_discountTotal discounts
cart.total_weightTotal weight in grams
cart.noteCart note from customer
cart.attributesCustom cart attributes
cart.currencyCurrency object

Line item properties: item.product, item.variant, item.title, item.quantity, item.price, item.line_price, item.url, item.image, item.properties, item.selling_plan_allocation

customer

Only available when logged in. Always check {% if customer %} first!

PropertyDescription
customer.first_nameFirst name
customer.last_nameLast name
customer.emailEmail address
customer.ordersArray of order objects
customer.orders_countTotal orders
customer.total_spentLifetime spend in cents
customer.tagsCustomer tags (for segmentation)
customer.addressesSaved addresses
customer.default_addressPrimary address

shop

PropertyDescription
shop.nameStore name
shop.urlStore URL
shop.emailStore email
shop.currencyDefault currency code
shop.money_formatMoney format string
shop.descriptionStore description
shop.localeCurrent locale
shop.metafieldsShop-level metafields

request

PropertyDescription
request.localeCurrent locale object
request.page_typePage type: product, collection, index, page, etc.
request.hostHostname
request.pathURL path
request.design_modeTrue if in theme editor (critical for conditional logic!)
request.design_mode is key! Use this to load preview data or adjust behavior when merchant is customizing in the theme editor.

Global Objects (Available Everywhere)

ObjectDescription
settingsTheme settings from settings_schema.json
routesStore routes: routes.cart_url, routes.account_url, etc.
page_titleSEO title for current page
page_descriptionSEO meta description
canonical_urlCanonical URL
templateCurrent template name (e.g., "product", "collection")
template.nameTemplate name
template.suffixAlternate template suffix
content_for_headerRequired in <head> - injects Shopify scripts
content_for_layoutRequired in <body> - renders template content
all_productsAccess any product: all_products['handle']
collectionsAccess any collection: collections['handle']
linklistsNavigation menus
pagesAccess pages by handle
blogsAccess blogs by handle

Liquid Tags Reference

Control Flow

{# if / elsif / else / endif #}
{% if product.available %}
  In Stock
{% elsif product.compare_at_price %}
  Coming Soon
{% else %}
  Sold Out
{% endif %}

{# unless (opposite of if) #}
{% unless product.title == 'Gift Card' %}
  Regular product
{% endunless %}

{# case / when #}
{% case template %}
  {% when 'product' %}
    Product page
  {% when 'collection' %}
    Collection page
  {% else %}
    Other page
{% endcase %}

{# Operators: == != > < >= <= or and contains #}
{% if product.tags contains 'sale' %}
{% if product.type == 'Shirt' and product.available %}

Iteration

{# for loop #}
{% for product in collection.products %}
  {{ product.title }}
  {{ forloop.index }}     {# 1, 2, 3... #}
  {{ forloop.index0 }}    {# 0, 1, 2... #}
  {{ forloop.first }}     {# true on first #}
  {{ forloop.last }}      {# true on last #}
  {{ forloop.length }}    {# total items #}
{% endfor %}

{# for with limit and offset #}
{% for product in collection.products limit:4 offset:2 %}
{% endfor %}

{# for with reversed #}
{% for item in cart.items reversed %}
{% endfor %}

{# for with range #}
{% for i in (1..5) %}
  {{ i }}
{% endfor %}

{# cycle - alternates between values #}
{% for product in collection.products %}
  <div class="{% cycle 'odd', 'even' %}">
{% endfor %}

{# tablerow - generates HTML table rows #}
{% tablerow product in collection.products cols:3 %}
  {{ product.title }}
{% endtablerow %}

Theme Tags (Critical to Know!)

{# render - MODERN way to include snippets (scoped) #}
{% render 'product-card', product: product, show_vendor: true %}

{# render with for - iterate within snippet #}
{% render 'product-card' for collection.products as product %}

{# section - statically include a section #}
{% section 'header' %}

{# sections - render a section group #}
{% sections 'header-group' %}

{# form #}
{% form 'product', product %}
  <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
  <button type="submit">Add to Cart</button>
{% endform %}

{# liquid tag - multi-line Liquid without {% %} on each line #}
{% liquid
  assign featured = collection.products | first
  if featured.available
    echo featured.title
  endif
%}

{# Variable tags #}
{% assign my_var = 'hello' %}
{% capture my_html %}<p>Hello {{ customer.name }}</p>{% endcapture %}
{{ my_html }}

{# increment / decrement #}
{% increment counter %}  {# outputs 0, 1, 2... #}

{# raw - outputs Liquid code without processing #}
{% raw %}{{ product.title }}{% endraw %}
render vs include {% include %} is DEPRECATED. Always use {% render %}. Key difference: render creates an isolated scope - the snippet cannot access outer variables unless explicitly passed. This is intentional for performance and maintainability.

Essential Liquid Filters

Money Filters

{{ product.price | money }}
{# $10.00 #}

{{ product.price | money_with_currency }}
{# $10.00 USD #}

{{ product.price | money_without_trailing_zeros }}
{# $10 #}

{{ product.price | money_without_currency }}
{# 10.00 #}

Prices are in cents! $10.00 = 1000

URL & Asset Filters

{{ 'style.css' | asset_url }}
{# CDN URL for theme asset #}

{{ 'style.css' | asset_url | stylesheet_tag }}
{# Full <link> tag #}

{{ 'app.js' | asset_url | script_tag }}
{# Full <script> tag #}

{{ product | product_url }}
{{ product.url | within: collection }}

{{ product.featured_image | image_url: width: 600 }}
{{ product.featured_image | image_url: width: 600 | image_tag }}

String Filters

upcase  HELLO
downcase  hello
capitalize  Hello
strip  trim whitespace
strip_html  remove HTML
truncate: 20  limit chars
truncatewords: 5  limit words
replace: 'old', 'new'
remove: 'string'
prepend: 'prefix'
append: 'suffix'
split: ', '  string to array
newline_to_br  \n to <br>
escape  HTML escape
url_encode
handleize  "My Product" → "my-product"

Array Filters

size  count items
first / last
join: ', '  array to string
sort: 'title'
sort_natural: 'title'
reverse
map: 'title'  extract property
where: 'available', true  FILTER!
concat: other_array
uniq  remove duplicates
compact  remove nils

{# Example: Get all available product titles #}
{{ collection.products
   | where: 'available', true
   | map: 'title'
   | join: ', ' }}

Translation Filter

{# t filter - for i18n from locale files #}
{{ 'products.product.add_to_cart' | t }}

{# With variables #}
{{ 'general.greeting' | t: name: customer.first_name }}

{# In locales/en.default.json: #}
{
  "products": {
    "product": {
      "add_to_cart": "Add to cart"
    }
  },
  "general": {
    "greeting": "Hello, {{ name }}!"
  }
}

Image Filters (Critical!)

{# Modern way - ALWAYS use this #}
{{ image | image_url: width: 800 }}
{# Returns CDN URL with size #}

{{ image | image_url: width: 800 | image_tag:
  loading: 'lazy',
  widths: '200,400,800',
  sizes: '(max-width: 768px) 100vw, 50vw',
  alt: product.title }}
{# Generates responsive <img> with srcset! #}

{{ product.featured_image | image_url: width: 100, height: 100, crop: 'center' }}

3 Theme File Structure

my-theme/ ├── assets/ CSS, JS, images, fonts - served from Shopify CDN │ ├── base.css │ ├── component-card.css │ └── global.js ├── blocks/ Theme blocks - reusable across sections (NEW in OS 2.0+) │ └── rating.liquid ├── config/ Theme settings definitions and stored values │ ├── settings_schema.json Defines all settings │ └── settings_data.json Stores current values + presets ├── layout/ Layout wrappers - theme.liquid is REQUIRED │ ├── theme.liquid REQUIRED - main layout │ └── password.liquid Password page layout ├── locales/ Translation files (i18n) │ ├── en.default.json │ └── fr.json ├── sections/ Modular content blocks with {% schema %} │ ├── header.liquid │ ├── main-product.liquid │ ├── footer.liquid │ ├── header-group.json Section group │ └── footer-group.json Section group ├── snippets/ Reusable partials - like Blade @include │ ├── product-card.liquid │ ├── icon-cart.liquid │ └── price.liquid └── templates/ Page templates - JSON (modern) or Liquid (legacy) ├── index.json Homepage ├── product.json Product page ├── collection.json Collection page ├── page.json Static page ├── blog.json Blog page ├── article.json Blog post ├── cart.json Cart page ├── search.json Search results ├── 404.json 404 page ├── password.json Password page ├── gift_card.liquid Must be Liquid ├── customers/ │ ├── login.json │ ├── register.json │ ├── account.json │ └── order.json └── metaobject/ Metaobject templates └── testimonial.json
Only layout/theme.liquid is strictly required for a valid theme. It must contain {{ content_for_header }} in <head> and {{ content_for_layout }} in <body>. No subdirectories beyond the defined ones are supported.

Layout File Structure (theme.liquid)

<!DOCTYPE html>
<html lang="{{ request.locale.iso_code }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ page_title }}</title>

  <!-- REQUIRED: Injects Shopify scripts, analytics, apps -->
  {{ content_for_header }}

  {{ 'base.css' | asset_url | stylesheet_tag }}
</head>
<body>

  <!-- Section groups replace static {% section 'header' %} -->
  {% sections 'header-group' %}

  <main id="MainContent" role="main" tabindex="-1">
    <!-- REQUIRED: Renders the current template -->
    {{ content_for_layout }}
  </main>

  {% sections 'footer-group' %}

</body>
</html>

4 Sections & Blocks

Sections are the building blocks of every Shopify page. Each section is a self-contained, configurable module.

JSON Templateproduct.json
Sectionmain-product.liquid
Block: Titletype: "title"
Block: Pricetype: "price"
Block: @appApp block

Complete Section with Schema

<!-- sections/featured-collection.liquid -->

{% schema %}
{
  "name": "Featured Collection",
  "tag": "section",
  "class": "featured-collection",
  "limit": 2,
  "settings": [
    {
      "type": "text",
      "id": "heading",
      "label": "Heading",
      "default": "Featured Products"
    },
    {
      "type": "collection",
      "id": "collection",
      "label": "Collection"
    },
    {
      "type": "range",
      "id": "products_to_show",
      "label": "Products to show",
      "min": 2,
      "max": 12,
      "step": 1,
      "default": 4
    },
    {
      "type": "select",
      "id": "columns",
      "label": "Columns",
      "options": [
        { "value": "2", "label": "2" },
        { "value": "3", "label": "3" },
        { "value": "4", "label": "4" }
      ],
      "default": "4"
    },
    {
      "type": "checkbox",
      "id": "show_vendor",
      "label": "Show vendor",
      "default": false
    }
  ],
  "blocks": [
    {
      "type": "product_card",
      "name": "Product Card",
      "limit": 12,
      "settings": [
        {
          "type": "checkbox",
          "id": "show_secondary_image",
          "label": "Show second image on hover",
          "default": false
        }
      ]
    },
    {
      "type": "@app"
    }
  ],
  "presets": [
    {
      "name": "Featured Collection",
      "category": "Collection"
    }
  ],
  "enabled_on": {
    "templates": ["index", "collection", "page"]
  }
}
{% endschema %}

<!-- Section HTML -->
<div class="section-{{ section.id }}">
  {% if section.settings.heading != blank %}
    <h2>{{ section.settings.heading }}</h2>
  {% endif %}

  {% assign collection = section.settings.collection %}
  {% if collection != blank %}
    <div class="grid grid--{{ section.settings.columns }}-col">
      {% for product in collection.products limit: section.settings.products_to_show %}
        {% render 'product-card', product: product, show_vendor: section.settings.show_vendor %}
      {% endfor %}
    </div>
  {% endif %}

  <!-- Render blocks (including @app blocks) -->
  {% for block in section.blocks %}
    {% case block.type %}
      {% when '@app' %}
        {% render block %}
      {% when 'product_card' %}
        <!-- Custom block content -->
    {% endcase %}
  {% endfor %}
</div>

<!-- Section-specific CSS (scoped) -->
{% style %}
  .section-{{ section.id }} {
    padding: 40px 0;
  }
{% endstyle %}

Schema Properties Reference

name
Display title in editor
tag
Wrapper element (div, section, article)
class
CSS class (always has shopify-section)
limit
Max times added (1 or 2)
settings
Input fields for merchants
blocks
Nested content types
presets
Default configs (required to show in "Add section")
enabled_on / disabled_on
Template restrictions (use ONE, never both)
block.shopify_attributes - Always include {{ block.shopify_attributes }} on each block's wrapper div. This enables the theme editor to identify and select blocks. Forgetting this is a common mistake!

Section Groups (Header/Footer)

Section groups replace static {% section 'header' %} calls. They let merchants add/remove/reorder sections in the header and footer.

// sections/header-group.json
{
  "type": "header",
  "name": "Header group",
  "sections": {
    "announcement-bar": {
      "type": "announcement-bar",
      "settings": {}
    },
    "header": {
      "type": "header",
      "settings": {}
    }
  },
  "order": ["announcement-bar", "header"]
}

<!-- In layout/theme.liquid: -->
{% sections 'header-group' %}  <!-- replaces {% section 'header' %} -->

Limits: Max 25 sections per group, max 50 blocks per section, max 20 group files per theme.

5 JSON Templates (Online Store 2.0)

Legacy (Liquid Templates)

  • Contain HTML + Liquid directly
  • Sections hardcoded with {% section 'name' %}
  • Merchants CANNOT add/remove/reorder sections
  • Section data stored in settings_data.json
  • Still required for gift_card.liquid

Modern (JSON Templates)

  • Pure data - no markup, no Liquid
  • Define sections + settings + order
  • Merchants CAN add/remove/reorder sections
  • Section data stored IN the template
  • Support app blocks from theme app extensions
  • Up to 25 sections per template
  • Up to 1,000 templates per theme

JSON Template Structure

// templates/product.json
{
  "layout": "theme",          // Which layout to use (or false for none)
  "wrapper": "main",          // Optional HTML wrapper element
  "sections": {
    "main": {                  // Section ID (your key)
      "type": "main-product",  // Maps to sections/main-product.liquid
      "disabled": false,
      "settings": {
        "show_vendor": true
      },
      "blocks": {
        "title_block": {
          "type": "title",
          "settings": {}
        },
        "price_block": {
          "type": "price",
          "settings": {}
        },
        "buy_button": {
          "type": "buy_buttons",
          "settings": {
            "show_dynamic_checkout": true
          }
        }
      },
      "block_order": ["title_block", "price_block", "buy_button"]
    },
    "recommendations": {
      "type": "product-recommendations",
      "settings": {
        "heading": "You may also like"
      }
    }
  },
  "order": ["main", "recommendations"]  // Render order
}
💡
Alternate Templates Create alternate templates using dot notation: product.special.json. Merchants can then assign different templates to different products in the admin. This is how you create unique layouts for specific products without changing the code.

6 Theme Settings

settings_schema.json

Defines all global theme settings. An array of category objects. Each category has a name and settings array.

[
  {
    "name": "theme_info",
    "theme_name": "My Theme",
    "theme_version": "1.0.0"
  },
  {
    "name": "Colors",
    "settings": [
      {
        "type": "color",
        "id": "color_primary",
        "label": "Primary color",
        "default": "#000000"
      }
    ]
  }
]

{# Access in Liquid: #}
{{ settings.color_primary }}

settings_data.json

Stores actual values. Has current (active values) and presets (up to 5 pre-configured designs). Max 1.5 MB.

{
  "current": {
    "color_primary": "#ff0000"
  },
  "presets": {
    "Default": {
      "color_primary": "#000000"
    },
    "Light": {
      "color_primary": "#ffffff"
    }
  }
}

All Setting Input Types

Basic
text, textarea, number, checkbox, radio, range, select
Color
color, color_background
Font
font_picker
Resource
collection, product, blog, page, article
Media
image_picker, video, video_url
Content
richtext, inline_richtext, html
Navigation
link_list, url
Meta
metaobject, metaobject_list, product_list, collection_list
Sidebar (no value)
header, paragraph

7 Metafields & Metaobjects

Accessing Metafields in Liquid

{# Pattern: resource.metafields.namespace.key #}
{{ product.metafields.custom.warranty }}
{{ product.metafields.custom.warranty.value }}
{{ page.metafields.custom.banner_image.value | image_url: width: 800 | image_tag }}
{{ shop.metafields.custom.announcement }}

{# Available on: products, variants, collections, #}
{# customers, orders, pages, articles, blogs, shop #}

Metaobjects - Custom Content Types

{# Global access (any template) #}
{{ metaobjects.testimonials.homepage.title }}
{{ metaobjects['highlights']['washable'].image.value }}

{# In metaobject templates (templates/metaobject/testimonial.json) #}
{{ metaobject.system.handle }}
{{ metaobject.field_key }}
{{ metaobject.field_key.value }}

{# Each metaobject entry gets a unique URL (SEO-friendly) #}
{# Must enable onlineStore capability on the definition #}

Dynamic Sources

Dynamic sources let merchants connect section/block settings to product metafields, collection data, etc. through the theme editor - no code changes needed. Limits: 100 per JSON template, 50 per setting.

8 Ajax API (Cart Operations)

The Ajax API provides REST endpoints for client-side cart operations. No authentication needed. Base URL: window.Shopify.routes.root

EndpointMethodPurpose
/cart.jsGETGet full cart state
/cart/add.jsPOSTAdd variant(s) to cart
/cart/update.jsPOSTUpdate quantities / attributes
/cart/change.jsPOSTChange specific line item
/cart/clear.jsPOSTEmpty the cart
/products/{handle}.jsGETFetch product JSON
/recommendations/products.jsonGETProduct recommendations
/search/suggest.jsonGETPredictive search

Cart Add Example

// Add to cart with Ajax API
fetch(window.Shopify.routes.root + 'cart/add.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: [{
      id: 36110175633573,       // Variant ID (not product ID!)
      quantity: 2,
      properties: {
        "Engraving": "Hello"   // Line item properties
      }
    }]
  })
})
.then(res => res.json())
.then(data => {
  // Update cart UI
  console.log(data);
});

// Section Rendering API - re-render sections without page reload
fetch('/cart/add.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: [{ id: variantId, quantity: 1 }],
    sections: 'cart-drawer,cart-icon-bubble'  // Request section HTML!
  })
})
.then(res => res.json())
.then(data => {
  // data.sections contains rendered HTML for each section
  document.getElementById('cart-drawer').innerHTML =
    data.sections['cart-drawer'];
});

Interview Tip - Section Rendering API

The Section Rendering API lets you request rendered section HTML alongside cart operations. Pass sections parameter with section IDs. The response includes pre-rendered HTML that you can swap into the DOM. This is how modern themes update the cart drawer and header cart count without a full page reload.

9 Performance Optimization

Target: 60+ average Lighthouse score across home, product, and collection pages for Theme Store.

JavaScript

Target <16 KB minified. No jQuery, no React/Vue/Angular. Use native DOM APIs. defer or async all scripts. Use Web Components for interactivity. Dawn uses zero frameworks.

CSS

Inline critical (above-fold) CSS in <head>. Use preload for render-blocking stylesheets. Avoid large frameworks. Use CSS custom properties for theming.

Images

Always use image_url + image_tag filters for responsive srcset. loading="lazy" on below-fold images only. Never lazy-load above the fold (hurts LCP). Set width/height to prevent CLS.

Fonts

Prefer system fonts (zero download). If web fonts: font-display: swap + preload critical files. Dawn defaults to system fonts.

Liquid Optimization

Sort/filter BEFORE loops, not inside. Use {% liquid %} tag to reduce whitespace. Run shopify theme check for issues. Use Theme Inspector Chrome extension.

Resource Loading

Max 2 preload hints per template. Host all assets on Shopify CDN (HTTP/2). Use preload_tag filter. Use stylesheet_tag: preload: true.

Image Best Practice Pattern

<!-- CORRECT: Responsive images with srcset -->
{{ product.featured_image
  | image_url: width: 1200
  | image_tag:
    loading: 'lazy',
    widths: '300,600,900,1200',
    sizes: '(max-width: 768px) 100vw, 50vw',
    alt: product.title
}}

<!-- ABOVE THE FOLD: No lazy loading! -->
{{ product.featured_image
  | image_url: width: 1200
  | image_tag:
    widths: '300,600,900,1200',
    sizes: '(max-width: 768px) 100vw, 50vw',
    fetchpriority: 'high',
    alt: product.title
}}

10 Accessibility (WCAG 2.0 AA)

Shopify themes must meet WCAG 2.0 Level AA. This is tested during Theme Store review and increasingly asked in interviews.

Keyboard Navigation

All interactive elements must be keyboard-operable. Focus order = DOM order. Visible focus indicators required. Escape closes modals. No context changes on focus.

ARIA Attributes

aria-expanded on collapsibles, aria-controls for hidden containers, aria-current for nav, aria-live for dynamic content (cart count), aria-describedby for errors.

Color Contrast

Text <24px: 4.5:1 ratio. Large text: 3:1. Icons: 3:1. Input borders: 3:1. Color alone must never convey information.

Semantic HTML

<nav> for navigation, <main> for content, logical h1-h6, <label for="..."> on inputs. lang attribute on <html>.

Skip Links

Must have "Skip to content" link. Visible on focus. Main content uses tabindex="-1" to receive focus.

Touch Targets

Minimum 44x44 CSS pixels for primary controls (buttons, menu links, cart controls, close buttons).

Skip Link Pattern

<!-- First element in body -->
<a href="#MainContent" class="skip-link">
  {{ 'accessibility.skip_to_content' | t }}
</a>

<!-- CSS -->
.skip-link {
  position: absolute;
  top: -100%;
  left: 50%;
  transform: translateX(-50%);
  z-index: 9999;
  padding: 12px 24px;
}
.skip-link:focus {
  top: 10px;
}

<!-- Target -->
<main id="MainContent" role="main" tabindex="-1">

11 Shopify CLI for Themes

CommandPurpose
shopify theme initScaffold new theme (defaults to Skeleton theme)
shopify theme devLocal dev server at 127.0.0.1:9292 with hot reload
shopify theme pushUpload local files to Shopify (overwrites remote)
shopify theme pullDownload theme from Shopify
shopify theme checkLint with Theme Check (errors + best practices)
shopify theme consoleInteractive Liquid REPL against live data
shopify theme publishMake theme live
shopify theme listList all themes with IDs
shopify theme packageCreate ZIP for distribution
shopify theme shareUpload with shareable preview link
shopify theme profileProfile Liquid execution (Speedscope)

Development Workflow

1
shopify theme initScaffold from Dawn or Skeleton theme
2
shopify theme devLocal development with hot reload. CSS + section changes hot reload; Liquid logic = full reload
3
shopify theme checkLint before pushing. Catches performance issues, accessibility problems, deprecations
4
shopify theme pushDeploy to store. Creates development theme (hidden, auto-deleted after 3 days)
5
shopify theme publishMake the theme live for customers

shopify.theme.toml - Environment Config

[environments.development]
store = "my-dev-store.myshopify.com"
theme = "123456789"

[environments.production]
store = "my-production-store.myshopify.com"

12 Theme App Extensions

Theme app extensions let apps inject functionality into themes without modifying theme code.

App Blocks

  • target: "section"
  • Placed within sections by merchants
  • Only work with JSON templates (OS 2.0)
  • Section must declare "type": "@app" in blocks
  • Rendered with {% render block %}

App Embeds

  • target: "head" or "body"
  • Injected globally (before </head> or </body>)
  • Work in ALL themes (legacy + OS 2.0)
  • Toggled on/off in "App embeds" tab
  • Used for: chat widgets, analytics, overlays

How Themes Support App Blocks

{# In section schema - declare @app block type #}
"blocks": [
  { "type": "slide", "name": "Slide" },
  { "type": "@app" }  <!-- enables app blocks -->
]

{# In section Liquid - render app blocks #}
{% for block in section.blocks %}
  {% case block.type %}
    {% when '@app' %}
      {% render block %}
    {% when 'slide' %}
      <div {{ block.shopify_attributes }}>
        <!-- slide content -->
      </div>
  {% endcase %}
{% endfor %}

13 Checkout Customization

Modern: Checkout UI Extensions

checkout.liquid (Shopify Plus only) is being sunset. The modern approach is Checkout UI Extensions.

  • Isolated sandbox (Remote DOM) - separate from checkout page
  • Preact-based with hooks (useState, useEffect) and Signals
  • Pre-built UI components: Banner, Text, Button, Stack, TextField, etc.
  • CSS locked to merchant branding - no custom CSS allowed
  • Max bundle size: 64 KB
  • Information/shipping/payment steps: Shopify Plus only

Shopify Functions (Backend Logic)

Replace Shopify Scripts (sunsetting June 30, 2026). Written in Rust (Wasm) or JavaScript. Server-side with strict execution limits. Used for custom discounts, shipping rates, payment methods.

14 JavaScript Patterns in Themes

Web Components (Dawn Pattern)

Dawn and modern themes use native Web Components (Custom Elements) instead of frameworks. This is the expected pattern.

// Custom element for product form
class ProductForm extends HTMLElement {
  constructor() {
    super();
    this.form = this.querySelector('form');
    this.submitButton = this.querySelector('[type="submit"]');
  }

  connectedCallback() {
    this.form.addEventListener('submit', this.onSubmit.bind(this));
  }

  async onSubmit(event) {
    event.preventDefault();
    this.submitButton.setAttribute('disabled', '');
    this.submitButton.classList.add('loading');

    const formData = new FormData(this.form);

    try {
      const response = await fetch(
        window.Shopify.routes.root + 'cart/add.js',
        {
          method: 'POST',
          body: formData
        }
      );
      const data = await response.json();

      // Publish custom event for cart drawer to listen
      document.dispatchEvent(
        new CustomEvent('cart:item-added', { detail: data })
      );
    } catch (error) {
      console.error('Error:', error);
    } finally {
      this.submitButton.removeAttribute('disabled');
      this.submitButton.classList.remove('loading');
    }
  }
}
customElements.define('product-form', ProductForm);

<!-- Usage in Liquid -->
<product-form>
  {% form 'product', product %}
    <input type="hidden" name="id"
      value="{{ product.selected_or_first_available_variant.id }}">
    <button type="submit">Add to Cart</button>
  {% endform %}
</product-form>

Section Rendering API Pattern

// Update cart drawer without page reload
async function updateCartDrawer() {
  const response = await fetch(
    `${window.Shopify.routes.root}cart.js?sections=cart-drawer`
  );
  const data = await response.json();

  // data.sections['cart-drawer'] contains rendered HTML
  const cartDrawer = document.getElementById('cart-drawer');
  const parser = new DOMParser();
  const doc = parser.parseFromString(
    data.sections['cart-drawer'], 'text/html'
  );
  cartDrawer.innerHTML = doc.querySelector('#cart-drawer').innerHTML;
}

// Variant change handler
function onVariantChange(variantId) {
  const url = new URL(window.location);
  url.searchParams.set('variant', variantId);

  fetch(`${url.pathname}?variant=${variantId}§ions=main-product`)
    .then(r => r.json())
    .then(data => {
      // Swap section HTML with new variant data
      const html = new DOMParser().parseFromString(
        data['main-product'], 'text/html'
      );
      document.querySelector('.product-info').innerHTML =
        html.querySelector('.product-info').innerHTML;
      window.history.replaceState({}, '', url);
    });
}

Predictive Search Implementation

// Predictive search using Ajax API
class PredictiveSearch extends HTMLElement {
  connectedCallback() {
    this.input = this.querySelector('input[type="search"]');
    this.results = this.querySelector('.search-results');
    this.input.addEventListener('input',
      this.debounce(this.onInput.bind(this), 300)
    );
  }

  async onInput() {
    const query = this.input.value.trim();
    if (query.length < 2) { this.results.innerHTML = ''; return; }

    const response = await fetch(
      `${window.Shopify.routes.root}search/suggest.json` +
      `?q=${encodeURIComponent(query)}` +
      `&resources[type]=product,collection,article` +
      `&resources[limit]=4`
    );
    const { resources } = await response.json();
    this.renderResults(resources);
  }

  debounce(fn, delay) {
    let timer;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn(...args), delay);
    };
  }
}
customElements.define('predictive-search', PredictiveSearch);

15 Quick Reference Cheat Sheet

Layout Required Tags
{{ content_for_header }} in <head> {{ content_for_layout }} in <body>
Include Snippets
{% render 'snippet-name', var: value %}
NOT {% include %} (deprecated)
Render Section Group
{% sections 'header-group' %}
Replaces static {% section 'header' %}
Product Form
{% form 'product', product %}...{% endform %}
input name="id" = variant ID
Responsive Image
{{ img | image_url: width: 800 | image_tag: loading: 'lazy' }}
Prices in Cents
{{ product.price | money }}
$10 = 1000. Always use money filter.
Translation
{{ 'key.path' | t }}
From locales/en.default.json
Pagination
{% paginate collection.products by 12 %}...{% endpaginate %}
Check Theme Editor
{% if request.design_mode %}...{% endif %}
Section-Specific CSS
{% style %} .section-{{ section.id }} { } {% endstyle %}
Asset URL
{{ 'file.css' | asset_url | stylesheet_tag }}
Cart Ajax Add
POST /cart/add.js { items: [{ id: variantId, quantity: 1 }] }

16 Practice Quiz

Test Your Knowledge

1. Which two objects are REQUIRED in every layout file?

2. What replaced {% include %} in modern Shopify themes?

3. In Shopify, product prices are stored in what unit?

4. What is the maximum number of sections allowed per JSON template?

5. Which command starts a local Shopify theme development server?

6. How do you render a section group in a layout file?

7. What should you add to every block's wrapper div for the theme editor?

8. What is the minimum color contrast ratio for body text in WCAG 2.0 AA?

9. What does the Section Rendering API allow you to do?

10. What framework does Dawn (Shopify's reference theme) use for JavaScript?

17 Interview Questions & Answers

Q: Explain the Shopify theme rendering pipeline Very Common

Q: What is Online Store 2.0 and how does it differ from legacy themes? Very Common

Q: What is the difference between {% render %} and {% include %}? Common

Q: How would you build an AJAX cart drawer? Common

Q: How do you optimize a Shopify theme for performance? Very Common

Q: Explain sections, blocks, and the schema tag Very Common

Q: How do theme app extensions work? Common

Q: How do you handle product variants in a theme? Common

Q: What accessibility requirements must Shopify themes meet? Common

Q: How would you approach debugging a slow Shopify theme? Advanced

Q: What are dynamic sources and how do they work? Advanced

Q: Compare and contrast sections, snippets, and theme blocks Advanced

18 Live Coding Challenges

Simulate a real interview. Write code, check your answer, and get scored. Each challenge has a timer, hints, and a model solution. Try to solve before peeking!

💻
How it works: Read the prompt, write your code in the editor, click "Check My Code" to get scored. The checker looks for key patterns a senior dev would include. Use "Hint" if stuck, "Show Solution" to learn. Your overall interview readiness score is at the bottom.

Challenge 1: Product Card Snippet

Easy Liquid 05:00

Interviewer: "Create a reusable product card snippet (snippets/product-card.liquid) that displays a product's image, title, price, and a sale badge when the product is on sale. The snippet should accept a product variable passed via {% render %}."

Requirements
  • Use image_url and image_tag filters for responsive images with lazy loading
  • Display product title as a link to the product page
  • Show current price with the money filter
  • Show compare-at price (strikethrough) and a "Sale" badge when on sale
  • Handle the case where the product has no image
snippets/product-card.liquid
Hints: (1) Check product.compare_at_price > product.price for sale. (2) Use product.featured_image | image_url: width: 600 | image_tag for images. (3) Use product.url for the link. (4) Wrap in a semantic element like <article> or <div class="product-card">.
Model Solution
<div class="product-card">
  <a href="{{ product.url }}" class="product-card__link">
    <div class="product-card__image">
      {% if product.featured_image %}
        {{ product.featured_image
          | image_url: width: 600
          | image_tag:
            loading: 'lazy',
            widths: '200,400,600',
            sizes: '(max-width: 768px) 50vw, 25vw',
            alt: product.title
        }}
      {% else %}
        {{ 'product-1' | placeholder_svg_tag: 'placeholder' }}
      {% endif %}

      {% if product.compare_at_price > product.price %}
        <span class="product-card__badge">Sale</span>
      {% endif %}
    </div>

    <div class="product-card__info">
      <h3 class="product-card__title">{{ product.title }}</h3>
      <div class="product-card__price">
        {% if product.compare_at_price > product.price %}
          <s>{{ product.compare_at_price | money }}</s>
        {% endif %}
        {{ product.price | money }}
      </div>
    </div>
  </a>
</div>

Challenge 2: Hero Banner Section Schema

Medium Schema 08:00

Interviewer: "Write a complete hero banner section with schema. The merchant should be able to configure: heading text, subheading, background image, button text, button URL, and overlay opacity. Support a 'slide' block type so merchants can add multiple slides. Limit to homepage only."

Requirements
  • Complete {% schema %} with name, settings, blocks, and presets
  • Include appropriate setting types (text, image_picker, url, range)
  • Slide block with its own image and heading settings
  • Use enabled_on to restrict to index template
  • Render the section HTML with blocks iteration
  • Include {{ block.shopify_attributes }} on block wrappers
  • Use section-scoped CSS with {% style %}
sections/hero-banner.liquid
Hints: (1) Schema goes in {% schema %} ... {% endschema %}. (2) Use "type": "image_picker" for images, "type": "range" for opacity. (3) Blocks are iterated with {% for block in section.blocks %}. (4) "enabled_on": { "templates": ["index"] }. (5) Don't forget presets or the section won't appear in "Add section".
Model Solution
<div class="hero-banner section-{{ section.id }}">
  {% for block in section.blocks %}
    {% case block.type %}
      {% when 'slide' %}
        <div class="hero-slide" {{ block.shopify_attributes }}>
          {% if block.settings.image %}
            {{ block.settings.image
              | image_url: width: 1920
              | image_tag:
                widths: '768,1024,1920',
                sizes: '100vw',
                alt: block.settings.heading
            }}
          {% endif %}
          <div class="hero-slide__overlay"></div>
          <div class="hero-slide__content">
            {% if block.settings.heading != blank %}
              <h2>{{ block.settings.heading }}</h2>
            {% endif %}
            {% if block.settings.subheading != blank %}
              <p>{{ block.settings.subheading }}</p>
            {% endif %}
            {% if block.settings.button_text != blank %}
              <a href="{{ block.settings.button_url }}" class="btn">
                {{ block.settings.button_text }}
              </a>
            {% endif %}
          </div>
        </div>
    {% endcase %}
  {% endfor %}
</div>

{% style %}
  .section-{{ section.id }} .hero-slide__overlay {
    background: rgba(0, 0, 0, {{ section.settings.overlay_opacity | divided_by: 100.0 }});
  }
{% endstyle %}

{% schema %}
{
  "name": "Hero Banner",
  "tag": "section",
  "class": "hero-banner-section",
  "settings": [
    {
      "type": "range",
      "id": "overlay_opacity",
      "label": "Overlay opacity",
      "min": 0,
      "max": 100,
      "step": 5,
      "default": 30,
      "unit": "%"
    }
  ],
  "blocks": [
    {
      "type": "slide",
      "name": "Slide",
      "limit": 6,
      "settings": [
        {
          "type": "image_picker",
          "id": "image",
          "label": "Background image"
        },
        {
          "type": "text",
          "id": "heading",
          "label": "Heading",
          "default": "Welcome to our store"
        },
        {
          "type": "textarea",
          "id": "subheading",
          "label": "Subheading"
        },
        {
          "type": "text",
          "id": "button_text",
          "label": "Button text",
          "default": "Shop now"
        },
        {
          "type": "url",
          "id": "button_url",
          "label": "Button link"
        }
      ]
    }
  ],
  "presets": [
    {
      "name": "Hero Banner",
      "category": "Image",
      "blocks": [
        { "type": "slide" }
      ]
    }
  ],
  "enabled_on": {
    "templates": ["index"]
  }
}
{% endschema %}

Challenge 3: Add to Cart with Ajax

Medium JavaScript 08:00

Interviewer: "Write a Web Component that handles add-to-cart functionality. It should intercept the form submit, call the Ajax API, update the cart drawer using the Section Rendering API, and show loading/error states. Use vanilla JS, no frameworks."

Requirements
  • Custom Element class extending HTMLElement
  • Use connectedCallback to set up event listeners
  • POST to /cart/add.js with FormData
  • Include sections parameter for Section Rendering API
  • Handle loading state (disable button, add class)
  • Handle errors gracefully
  • Dispatch custom event so other components can react
  • Register with customElements.define()
assets/product-form.js
Hints: (1) class ProductForm extends HTMLElement. (2) Use this.querySelector('form') in constructor. (3) fetch(routes.root + 'cart/add.js', { method:'POST', body: formData }). (4) Add sections to formData or JSON body. (5) document.dispatchEvent(new CustomEvent('cart:updated', { detail: data })). (6) customElements.define('product-form', ProductForm).
Model Solution
class ProductForm extends HTMLElement {
  constructor() {
    super();
    this.form = this.querySelector('form');
    this.submitButton = this.querySelector('[type="submit"]');
    this.errorMessage = this.querySelector('.error-message');
  }

  connectedCallback() {
    this.form.addEventListener('submit', this.onSubmit.bind(this));
  }

  async onSubmit(event) {
    event.preventDefault();

    // Loading state
    this.submitButton.setAttribute('disabled', '');
    this.submitButton.classList.add('loading');
    if (this.errorMessage) this.errorMessage.textContent = '';

    const formData = new FormData(this.form);

    try {
      const response = await fetch(
        `${window.Shopify.routes.root}cart/add.js`,
        {
          method: 'POST',
          headers: { 'X-Requested-With': 'XMLHttpRequest' },
          body: formData
        }
      );

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.description || 'Failed to add to cart');
      }

      const item = await response.json();

      // Fetch updated section HTML via Section Rendering API
      const sectionsResponse = await fetch(
        `${window.Shopify.routes.root}cart.js?sections=cart-drawer,cart-icon-bubble`
      );
      const cartData = await sectionsResponse.json();

      // Dispatch event for cart drawer and other listeners
      document.dispatchEvent(
        new CustomEvent('cart:item-added', {
          detail: { item, sections: cartData.sections },
          bubbles: true
        })
      );
    } catch (error) {
      if (this.errorMessage) {
        this.errorMessage.textContent = error.message;
      }
      console.error('Add to cart error:', error);
    } finally {
      this.submitButton.removeAttribute('disabled');
      this.submitButton.classList.remove('loading');
    }
  }
}

customElements.define('product-form', ProductForm);

Challenge 4: JSON Template for Product Page

Easy JSON Template 05:00

Interviewer: "Write a JSON template for a product page (templates/product.json). It should include a main product section with title, price, variant picker, and buy buttons blocks, followed by a product recommendations section."

Requirements
  • Valid JSON structure with sections and order
  • Main product section with multiple blocks and block_order
  • Product recommendations section
  • Use the theme layout
templates/product.json
Hints: (1) Top-level keys: "layout", "sections", "order". (2) Each section needs a "type" matching a file in sections/. (3) Blocks go inside a section with "blocks" and "block_order". (4) "order" is an array of section keys.
Model Solution
{
  "layout": "theme",
  "sections": {
    "main": {
      "type": "main-product",
      "settings": {},
      "blocks": {
        "title": {
          "type": "title",
          "settings": {}
        },
        "price": {
          "type": "price",
          "settings": {}
        },
        "variant_picker": {
          "type": "variant_picker",
          "settings": {
            "picker_type": "button"
          }
        },
        "quantity": {
          "type": "quantity_selector",
          "settings": {}
        },
        "buy_buttons": {
          "type": "buy_buttons",
          "settings": {
            "show_dynamic_checkout": true
          }
        },
        "description": {
          "type": "description",
          "settings": {}
        }
      },
      "block_order": [
        "title",
        "price",
        "variant_picker",
        "quantity",
        "buy_buttons",
        "description"
      ]
    },
    "recommendations": {
      "type": "product-recommendations",
      "settings": {
        "heading": "You may also like"
      }
    }
  },
  "order": ["main", "recommendations"]
}

Challenge 5: Collection Page with Pagination & Filters

Medium Liquid 08:00

Interviewer: "Write the Liquid for a collection page section that displays products in a grid with pagination (12 per page) and storefront filtering support. Include a product count, active filter tags, and use responsive images."

Requirements
  • Wrap in {% paginate collection.products by 12 %}
  • Display active filters with remove links
  • Loop through collection.filters for filter UI
  • Product grid using {% render 'product-card' %}
  • Include pagination with {{ paginate | default_pagination }}
  • Handle empty collections gracefully
sections/main-collection.liquid
Hints: (1) {% paginate collection.products by 12 %} wraps everything. (2) collection.filters gives you filter groups, each with filter.values. (3) Active filters: filter.active_values. (4) Render products with {% render 'product-card', product: product %}. (5) Handle empty with {% if collection.products_count == 0 %}.
Model Solution
{% paginate collection.products by 12 %}
<div class="collection section-{{ section.id }}">
  <h1>{{ collection.title }}</h1>
  <p class="collection__count">{{ collection.products_count }} products</p>

  <!-- Active Filters -->
  {% assign active_filters = false %}
  {% for filter in collection.filters %}
    {% if filter.active_values.size > 0 %}
      {% assign active_filters = true %}
    {% endif %}
  {% endfor %}

  {% if active_filters %}
    <div class="active-filters">
      {% for filter in collection.filters %}
        {% for value in filter.active_values %}
          <a href="{{ value.url_to_remove }}" class="active-filter__tag">
            {{ value.label }} &times;
          </a>
        {% endfor %}
      {% endfor %}
    </div>
  {% endif %}

  <!-- Filters -->
  <form class="collection__filters">
    {% for filter in collection.filters %}
      <details>
        <summary>{{ filter.label }}</summary>
        {% case filter.type %}
          {% when 'list' %}
            {% for value in filter.values %}
              <label>
                <input type="checkbox"
                  name="{{ value.param_name }}"
                  value="{{ value.value }}"
                  {% if value.active %}checked{% endif %}
                  {% if value.count == 0 %}disabled{% endif %}
                >
                {{ value.label }} ({{ value.count }})
              </label>
            {% endfor %}
          {% when 'price_range' %}
            <input type="number" name="{{ filter.min_value.param_name }}"
              placeholder="From" value="{{ filter.min_value.value }}">
            <input type="number" name="{{ filter.max_value.param_name }}"
              placeholder="To" value="{{ filter.max_value.value }}">
        {% endcase %}
      </details>
    {% endfor %}
  </form>

  <!-- Product Grid -->
  {% if collection.products.size > 0 %}
    <div class="collection__grid">
      {% for product in collection.products %}
        {% render 'product-card', product: product %}
      {% endfor %}
    </div>
  {% else %}
    <p>{{ 'collections.general.no_matches' | t }}</p>
  {% endif %}

  <!-- Pagination -->
  {% if paginate.pages > 1 %}
    <nav aria-label="Pagination">
      {{ paginate | default_pagination }}
    </nav>
  {% endif %}
</div>
{% endpaginate %}

Challenge 6: Variant Selector with Section Rendering API

Hard JS + Liquid 12:00

Interviewer: "Write the JavaScript for a variant selector Web Component. When a customer selects a different variant, it should update the URL, fetch the new section HTML via the Section Rendering API, and swap the product info without a full page reload."

Requirements
  • Web Component extending HTMLElement
  • Listen to variant option changes
  • Find matching variant from product JSON data
  • Update URL with history.replaceState
  • Fetch section HTML via ?variant=ID§ions=main-product
  • Parse response and swap DOM content
  • Update the hidden form input with new variant ID
  • Handle unavailable variants
assets/variant-selector.js
Hints: (1) Store variant data: JSON.parse(this.querySelector('[type="application/json"]').textContent). (2) On option change, combine selected options and .find() matching variant. (3) fetch(`${url}?variant=${id}§ions=main-product`). (4) Parse with new DOMParser().parseFromString(html, 'text/html'). (5) history.replaceState({}, '', newUrl).
Model Solution
class VariantSelector extends HTMLElement {
  constructor() {
    super();
    this.variants = JSON.parse(
      this.querySelector('[type="application/json"]').textContent
    );
    this.productForm = document.querySelector('product-form');
    this.sectionId = this.dataset.section;
  }

  connectedCallback() {
    this.addEventListener('change', this.onVariantChange.bind(this));
  }

  onVariantChange() {
    const selectedOptions = Array.from(
      this.querySelectorAll('select, fieldset input:checked'),
      (el) => el.value
    );

    // Find matching variant
    const variant = this.variants.find(v =>
      v.options.every((opt, i) => opt === selectedOptions[i])
    );

    if (!variant) {
      this.setUnavailable();
      return;
    }

    // Update hidden form input
    const input = this.productForm?.querySelector('input[name="id"]');
    if (input) input.value = variant.id;

    // Update URL
    const url = new URL(window.location);
    url.searchParams.set('variant', variant.id);
    window.history.replaceState({}, '', url);

    // Fetch updated section HTML
    this.fetchSectionHTML(variant.id);
  }

  async fetchSectionHTML(variantId) {
    try {
      const url = `${window.location.pathname}?variant=${variantId}§ions=${this.sectionId}`;
      const response = await fetch(url);
      const data = await response.json();

      const html = new DOMParser().parseFromString(
        data[this.sectionId], 'text/html'
      );

      // Swap price
      const priceSource = html.querySelector('.price');
      const priceTarget = document.querySelector('.price');
      if (priceSource && priceTarget) {
        priceTarget.innerHTML = priceSource.innerHTML;
      }

      // Swap buy button state
      const btnSource = html.querySelector('.product-form__submit');
      const btnTarget = document.querySelector('.product-form__submit');
      if (btnSource && btnTarget) {
        btnTarget.disabled = btnSource.disabled;
        btnTarget.textContent = btnSource.textContent;
      }

      // Dispatch event
      document.dispatchEvent(
        new CustomEvent('variant:changed', {
          detail: { variantId },
          bubbles: true
        })
      );
    } catch (error) {
      console.error('Error fetching variant:', error);
    }
  }

  setUnavailable() {
    const btn = document.querySelector('.product-form__submit');
    if (btn) {
      btn.disabled = true;
      btn.textContent = 'Unavailable';
    }
  }
}

customElements.define('variant-selector', VariantSelector);

Challenge 7: Accessible Cart Drawer

Hard A11y + JS 10:00

Interviewer: "Write the HTML structure and JavaScript for an accessible cart drawer. It must meet WCAG 2.0 AA requirements. Focus on the accessibility aspects - proper ARIA attributes, keyboard navigation, focus management."

Requirements
  • Use role="dialog" and aria-modal="true"
  • aria-label or aria-labelledby on the drawer
  • Focus trap - Tab/Shift+Tab cycles within the drawer
  • Escape key closes the drawer
  • Focus moves to first focusable element on open
  • Focus returns to trigger element on close
  • aria-live region for cart count updates
  • Close button with aria-label
HTML + JavaScript for cart drawer
Hints: (1) role="dialog" aria-modal="true" aria-label="Shopping cart". (2) Query all focusable elements: querySelectorAll('a,button,input,select,textarea,[tabindex]'). (3) On keydown, if Tab and last element is focused, move to first. (4) On Escape, close and triggerElement.focus(). (5) aria-live="polite" on cart count span.
Model Solution
<!-- Cart icon with live region -->
<button id="cart-toggle" aria-controls="cart-drawer" aria-expanded="false">
  Cart (<span aria-live="polite" class="cart-count">0</span>)
</button>

<!-- Cart Drawer -->
<div id="cart-drawer"
  role="dialog"
  aria-modal="true"
  aria-label="Shopping cart"
  hidden
>
  <div class="cart-drawer__header">
    <h2 id="cart-title">Your Cart</h2>
    <button class="cart-drawer__close" aria-label="Close cart">
      &times;
    </button>
  </div>
  <div class="cart-drawer__body">
    <!-- Cart items render here -->
  </div>
  <div class="cart-drawer__footer">
    <a href="/checkout" class="btn">Checkout</a>
  </div>
</div>
<div class="cart-drawer__overlay" hidden></div>

<script>
class CartDrawer {
  constructor() {
    this.drawer = document.getElementById('cart-drawer');
    this.toggle = document.getElementById('cart-toggle');
    this.closeBtn = this.drawer.querySelector('.cart-drawer__close');
    this.overlay = document.querySelector('.cart-drawer__overlay');
    this.triggerElement = null;

    this.toggle.addEventListener('click', () => this.open());
    this.closeBtn.addEventListener('click', () => this.close());
    this.overlay.addEventListener('click', () => this.close());
    this.drawer.addEventListener('keydown', (e) => this.onKeydown(e));

    document.addEventListener('cart:item-added', () => this.open());
  }

  open() {
    this.triggerElement = document.activeElement;
    this.drawer.hidden = false;
    this.overlay.hidden = false;
    this.toggle.setAttribute('aria-expanded', 'true');
    document.body.style.overflow = 'hidden';

    // Focus first focusable element
    const focusable = this.getFocusableElements();
    if (focusable.length) focusable[0].focus();
  }

  close() {
    this.drawer.hidden = true;
    this.overlay.hidden = true;
    this.toggle.setAttribute('aria-expanded', 'false');
    document.body.style.overflow = '';

    // Return focus to trigger
    if (this.triggerElement) this.triggerElement.focus();
  }

  onKeydown(event) {
    if (event.key === 'Escape') {
      this.close();
      return;
    }

    if (event.key !== 'Tab') return;

    // Focus trap
    const focusable = this.getFocusableElements();
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  }

  getFocusableElements() {
    return [...this.drawer.querySelectorAll(
      'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )];
  }
}

new CartDrawer();
</script>

Challenge 8: Write theme.liquid from Scratch

Medium Layout 07:00

Interviewer: "Write the layout/theme.liquid file from scratch. Include everything a production theme needs: proper meta tags, SEO, accessibility, section groups, and asset loading with performance in mind."

Requirements
  • {{ content_for_header }} in <head> and {{ content_for_layout }} in <body>
  • Proper lang attribute using request.locale
  • SEO: page_title, meta description, canonical URL
  • Skip link for accessibility
  • Section groups for header and footer
  • CSS loaded with performance in mind
  • Main content area with role="main" and tabindex="-1"
layout/theme.liquid
Hints: (1) <html lang="{{ request.locale.iso_code }}">. (2) {{ content_for_header }} is a black box - just include it. (3) {% sections 'header-group' %} instead of {% section 'header' %}. (4) Skip link goes right after <body>. (5) {{ 'base.css' | asset_url | stylesheet_tag }}.
Model Solution
<!DOCTYPE html>
<html lang="{{ request.locale.iso_code }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>{{ page_title }}{% unless page_title contains shop.name %} - {{ shop.name }}{% endunless %}</title>

  {% if page_description %}
    <meta name="description" content="{{ page_description | escape }}">
  {% endif %}

  <link rel="canonical" href="{{ canonical_url }}">

  {{ content_for_header }}

  {{ 'base.css' | asset_url | stylesheet_tag: preload: true }}
  {{ 'component-cart.css' | asset_url | stylesheet_tag }}

  <script>
    document.documentElement.className =
      document.documentElement.className.replace('no-js', 'js');
  </script>
</head>

<body class="template-{{ template.name }}">
  <a href="#MainContent" class="skip-to-content">
    {{ 'accessibility.skip_to_content' | t }}
  </a>

  {% sections 'header-group' %}

  <main id="MainContent" role="main" tabindex="-1">
    {{ content_for_layout }}
  </main>

  {% sections 'footer-group' %}

  <script src="{{ 'global.js' | asset_url }}" defer></script>
</body>
</html>

20 Shopify App Architecture

You already build Shopify apps with Laravel. Here's the architecture knowledge a senior dev must articulate in an interview.

App Types

Public Apps

Distributed via App Store. Must pass review. OAuth required. Since April 2025: GraphQL only for new public apps. By April 2026: must use expiring offline tokens.

Custom Apps

Single store. Created via Partner Dashboard or Admin. Can use admin-generated tokens or OAuth. Functions require Shopify Plus.

Private Apps

Deprecated Jan 2022 Auto-converted to custom apps. Used basic HTTP auth. If you see these in legacy systems, migrate immediately.

Embedded App Architecture

Shopify AdminParent frame
iframeYour app URL
App BridgeSession token JWT
Your BackendValidates JWT
Shopify APIAccess token

Session Token Flow (Modern - No Cookies)

// 1. App Bridge auto-generates JWT (1 min lifetime)
// 2. authenticatedFetch sends it as Bearer token
// 3. Your backend validates + exchanges for access token

// JWT payload:
{
  "iss": "https://store.myshopify.com/admin",
  "dest": "https://store.myshopify.com",
  "aud": "YOUR_API_KEY",
  "sub": "user_id",
  "exp": 1234567890,   // 1 minute lifetime!
  "sid": "session-id"
}

// Token exchange (your backend -> Shopify):
POST https://{shop}/admin/oauth/access_token
{
  "client_id": "API_KEY",
  "client_secret": "API_SECRET",
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token": "{session_token_jwt}",
  "subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
  "requested_token_type": "urn:shopify:params:oauth:token-type:offline-access-token"
}
Session tokens != Access tokens. Session tokens authenticate frontend-to-YOUR-backend. Access tokens authenticate YOUR-backend-to-Shopify-API. Never use session tokens to call Shopify APIs directly.

Online vs Offline Access Tokens

Online Tokens

  • Tied to individual user session
  • Expires when user logs out or 24h
  • Respects staff member permissions
  • Use for: embedded admin UI

Offline Tokens

  • Tied to the store (app installation)
  • Now: 24h expiry + 90-day refresh token
  • Full app scopes regardless of user
  • Use for: webhooks, cron jobs, background sync

Extension Types

ExtensionWhereLanguageKey Detail
Theme App ExtensionStorefrontLiquid + CSS/JSApp blocks in sections, app embeds globally
Checkout UI ExtensionCheckoutPreact/JSX64KB limit, Plus-only for payment steps
Admin UI ExtensionAdmin panelsPreact/JSXBlocks, actions, selection actions on resources
Shopify FunctionsServer-side (edge)Rust/JS → Wasm<5ms, no network, 11M instruction limit

GDPR Mandatory Webhooks

Every app must implement these 3 webhooks or it gets rejected from the App Store:

TopicTriggerAction Required
customers/data_requestCustomer requests their dataProvide all stored data within 30 days
customers/redactCustomer requests deletionDelete customer data within 30 days
shop/redact48h after app uninstallErase ALL store data within 30 days

Interview Tip

"Why session tokens over cookies?" → Third-party cookie deprecation. Embedded apps run in iframes, and browsers block cross-origin cookies. Session tokens (JWTs via App Bridge) solve this without cookies.

21 API Design & Webhooks

REST vs GraphQL Admin API

REST Admin API (Legacy)

  • Multiple endpoints per resource
  • Fixed response shape (over-fetching)
  • Rate limit: 40 req/60s (bucket)
  • No bulk operations
  • New public apps CANNOT use REST (April 2025)

GraphQL Admin API (Required)

  • Single endpoint, request exact fields
  • Cost-based rate limiting (more efficient)
  • Native bulk operations (bypass rate limits)
  • Gets new features first (or exclusively)
  • Cursor-based pagination (Relay spec)

GraphQL Rate Limiting

Leaky bucket based on query cost, not request count:

PlanBucket SizeRestore Rate
Standard1,000 points50 pts/sec
Advanced2,000 points100 pts/sec
Plus10,000 points500 pts/sec
// Every response includes cost info:
{
  "extensions": {
    "cost": {
      "requestedQueryCost": 152,   // estimated before execution
      "actualQueryCost": 48,       // actual (refund difference)
      "throttleStatus": {
        "maximumAvailable": 1000,
        "currentlyAvailable": 852,
        "restoreRate": 50
      }
    }
  }
}

// Single query max: 1,000 points (hard cap)
// REST: 40 requests per 60 seconds per store

Webhook Architecture

1
Event Occurse.g., orders/create fires when a new order is placed
2
Shopify POSTsSends payload to your HTTPS endpoint (or Pub/Sub / EventBridge)
3
You Verify HMACX-Shopify-Hmac-SHA256 = HMAC-SHA256 of raw body with client secret
4
Respond 200 ImmediatelyShopify waits only 5 seconds. Queue the payload, process async.
5
DeduplicateCheck X-Shopify-Event-Id header. Same ID on retries. Store in Redis with TTL.
6
Process IdempotentlySame event processed twice must produce same result. Use timestamps, not order of arrival.

Key Webhook Headers

HeaderPurpose
X-Shopify-Hmac-SHA256HMAC signature (verify authenticity)
X-Shopify-TopicEvent type: orders/create
X-Shopify-Event-IdUnique per event (use for deduplication)
X-Shopify-Triggered-AtTimestamp of event (use for ordering)
X-Shopify-Shop-DomainStore domain

Retry policy: 8 retries over 4 hours. After 8 failures: subscription auto-deleted. Delivery: at-least-once, no ordering guarantee.

Bulk Operations

// Bulk query - bypasses rate limits entirely
mutation {
  bulkOperationRunQuery(query: """
    { products { edges { node { id title variants { edges { node { id price } } } } } } }
  """) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

// Results: JSONL file, download URL valid 7 days
// Monitor via: bulk_operations/finish webhook
// Up to 5 concurrent bulk ops per shop (API 2026-01)

// When to use:
// - Data > 250 records
// - Full catalog sync
// - Migration / import tasks

22 Data Consistency & Reliability

Eventual Consistency Pattern

Shopify is eventually consistent. Webhooks can arrive out of order, be duplicated, or be lost entirely. A senior dev must design for this.

// The reliable pattern: Webhooks + Polling Fallback

// Layer 1: Real-time webhooks (primary)
POST /webhooks/orders-create
  → Validate HMAC
  → Check X-Shopify-Event-Id (dedup in Redis, 24h TTL)
  → Respond 200 immediately
  → Queue for async processing
  → Process idempotently (check updated_at timestamp)

// Layer 2: Reconciliation polling (safety net)
// Every 5-15 minutes:
query {
  orders(query: "updated_at:>='2026-03-29T00:00:00Z'") {
    edges { node { id updatedAt } }
  }
}
// Use 5-min overlap buffer for clock skew

// Layer 3: Full sync (weekly backup)
// bulkOperationRunQuery for complete data reconciliation

Data Storage Decision Matrix

NeedSolutionWhy
Custom field on product/orderMetafield (app-owned)Renders in Liquid, visible in admin, filterable
New custom entityMetaobjectLike a custom DB table in Shopify, gets URL routing
Per-install configApp-data metafieldHidden from admin, scoped to your app
High-volume dataExternal DB (Postgres)Complex queries, joins, no API rate limits
Sensitive credentialsExternal encrypted storageNever store secrets in metafields

Idempotency

// GraphQL mutations support Idempotency-Key header
// Shopify caches response for 60 seconds per key

fetch('/admin/api/2026-01/graphql.json', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Shopify-Access-Token': token,
    'Idempotency-Key': 'unique-uuid-per-operation'  // never reuse
  },
  body: JSON.stringify({ query: mutation })
});

// For webhooks: use X-Shopify-Event-Id as dedup key
// For your own jobs: generate UUID, store in DB before executing

Reliability Patterns

Queue-First Webhooks

Respond 200 instantly. Push to Redis/SQS queue. Worker processes async. Shopify's 5-second timeout means no heavy processing inline.

Exponential Backoff

On 429 (rate limited): wait 2^n seconds. Monitor extensions.cost.throttleStatus in every GraphQL response to predict limits.

Graceful Degradation

If Shopify API is down, serve cached data. Never let API failures break the merchant's storefront. Cache metafields locally.

Webhook Reconciliation

Webhooks can be lost. Poll with updated_at filter every 5-15 min. Use bulk operations for full sync weekly.

23 Technical Tradeoffs & Business Value

Senior engineers don't just pick technologies - they explain why in terms of business impact.

Embedded vs Standalone App

Embedded (Recommended)

  • Runs inside Shopify Admin iframe
  • Native look with Polaris + App Bridge
  • Session token auth (no cookies)
  • Required for "Built for Shopify" badge

Standalone

  • Own domain, own design
  • Cookie-based sessions work
  • Better for complex dashboards / external users
  • Loses admin integration benefits

Business value: Embedded = lower merchant friction, higher retention. Merchants don't leave the admin. "Built for Shopify" badge increases trust and install rate.

Webhooks vs Polling

FactorWebhooksPolling
LatencyNear real-timeDelayed (interval-based)
ReliabilityCan lose eventsCatches everything eventually
API costZero (push-based)Consumes rate limit budget
OrderingNot guaranteedYou control the order

Senior answer: "Use both. Webhooks for real-time reactivity, polling as a safety net for missed events. This gives us speed AND reliability."

Metafields vs External Database

FactorMetafieldsExternal DB
Access in LiquidDirect: product.metafields.app.keyNeed app proxy or API call
Admin visibilityVisible to merchantsHidden
Query complexityLimited (no joins)Full SQL power
Rate limitsCounts against API limitsYour own limits
Data volumeGood for small-mediumUnlimited

Senior answer: "Metafields for data merchants need to see or that renders in themes. External DB for high-volume analytics, complex queries, or sensitive data."

Theme App Extension vs ScriptTag (Legacy)

FactorTheme App ExtensionScriptTag
Theme editorDrag-and-drop by merchantInvisible, injected
PerformanceAssets on Shopify CDNExternal script, blocks rendering
Uninstall cleanupAutomaticMay leave orphaned scripts
OS 2.0 themesFull supportWorks but discouraged

Business value: Theme app extensions = better Lighthouse scores (CDN-hosted), cleaner uninstall, merchant control. All translate to higher merchant satisfaction and fewer support tickets.

Shopify Functions vs Custom Backend Logic

FactorShopify FunctionsCustom Backend
ExecutionShopify edge, <5msYour server, variable latency
ReliabilityShopify manages uptimeYou manage uptime
FlexibilityNo network, no state, 11M instructionsFull flexibility
Use casesDiscounts, payment, delivery, cartEverything else

Senior answer: "Functions for checkout-critical logic where latency matters and Shopify guarantees uptime. Backend for everything that needs network access, state, or complex business logic."

The Framework for Any Tradeoff Question

1. State both options clearly. 2. Name the tradeoff axis (latency vs flexibility, simplicity vs control, etc.). 3. State your recommendation. 4. Tie it to business value: conversion rate, page speed, merchant UX, developer velocity, maintenance cost, or reliability.

24 Senior Engineer Quiz

Test Your Senior-Level Knowledge

1. Since April 2025, new public Shopify apps must use which API?

2. What is the lifetime of a session token JWT from App Bridge?

3. How should you handle webhook processing to avoid Shopify's 5-second timeout?

4. Which header do you use to deduplicate webhook retries?

5. What happens after 8 consecutive webhook delivery failures?

6. What is the GraphQL Admin API rate limit bucket size for standard plans?

7. Why should you use both webhooks AND polling for data sync?

8. Which access token type should you use for background cron jobs?

9. Shopify Functions execute as what runtime format?

10. Which GDPR webhook is sent 48 hours after app uninstall?

21 Code Walkthrough - How to Present Your Work

The interviewer will ask you to walk through code you're proud of. This is NOT a quiz - it's a conversation about your thinking, decisions, and tradeoffs. Here's how a senior dev structures this.

💬
What the recruiter told you: "Discuss a piece of Shopify-related code you're proud of (React app with Polaris, backend work for a Shopify app, or a theme with Liquid/JavaScript). Review the code together and talk through the problem it solves and your implementation approach."

The Senior Framework: STAR-T

S
Situation"The merchant needed X because Y. The existing solution had Z problem."
T
Task"I was responsible for designing and implementing the solution. The constraints were..."
A
Approach"I chose X over Y because... The tradeoff was... I structured it this way because..."
R
Result"This reduced page load by X%, increased conversion by Y%, reduced support tickets by Z%."
T
Tradeoffs & What I'd Change"If I rebuilt it today, I'd change X because... The limitation is Y, and here's how I'd address it."

What Interviewers Actually Evaluate

Problem Understanding

Can you articulate the business problem clearly? Do you understand the merchant's pain point, not just the technical requirement?

Decision Justification

Why did you pick this architecture? What alternatives did you consider? Why NOT the other options? This shows depth.

Code Quality Awareness

Can you point to patterns you're proud of AND things you'd improve? Self-awareness > perfection. Seniors know their code's weaknesses.

Business Impact

How did this help the merchant? Conversion, performance, reliability, developer velocity? Tie every technical choice to a business outcome.

3 Code Examples Ready to Present

Since you built Shopify public apps with Laravel, prepare these three angles. Each includes the code pattern, how to explain it, and what questions to expect.

Example 1: Webhook Handler (Laravel Backend)

Pitch: "I built a reliable webhook processing system for our Shopify app that handles orders/create, products/update, and GDPR compliance webhooks."

// app/Http/Middleware/VerifyShopifyWebhook.php
class VerifyShopifyWebhook
{
    public function handle(Request $request, Closure $next)
    {
        $hmac = $request->header('X-Shopify-Hmac-SHA256');
        $data = $request->getContent();  // raw body BEFORE parsing
        $calculated = base64_encode(
            hash_hmac('sha256', $data, config('shopify.secret'), true)
        );

        if (!hash_equals($calculated, $hmac)) {
            return response('Unauthorized', 401);
        }

        return $next($request);
    }
}

// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $eventId = $request->header('X-Shopify-Event-Id');

        // Deduplication: skip if already processed
        if (Cache::has("webhook:{$eventId}")) {
            return response('', 200);
        }
        Cache::put("webhook:{$eventId}", true, now()->addHours(24));

        // Respond 200 immediately, process async
        $topic = $request->header('X-Shopify-Topic');
        $shop = $request->header('X-Shopify-Shop-Domain');

        ProcessWebhookJob::dispatch($topic, $shop, $request->all());

        return response('', 200);
    }
}

// app/Jobs/ProcessWebhookJob.php
class ProcessWebhookJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $backoff = [30, 120, 600];

    public function handle()
    {
        match ($this->topic) {
            'orders/create'          => $this->handleOrderCreate(),
            'products/update'        => $this->handleProductUpdate(),
            'customers/redact'       => $this->handleCustomerRedact(),
            'shop/redact'            => $this->handleShopRedact(),
            'customers/data_request' => $this->handleDataRequest(),
        };
    }

    private function handleProductUpdate()
    {
        $product = $this->payload;
        // Idempotent: use updated_at, not arrival order
        $existing = Product::find($product['id']);
        if ($existing && $existing->shopify_updated_at >= $product['updated_at']) {
            return; // Already have newer data
        }
        Product::updateOrCreate(
            ['shopify_id' => $product['id']],
            ['title' => $product['title'], 'shopify_updated_at' => $product['updated_at']]
        );
    }
}

How to Walk Through This

🗣
Say: "The biggest challenge with Shopify webhooks is reliability. They can arrive out of order, be duplicated, or be lost entirely. So I built three layers of defense: HMAC verification in middleware to ensure authenticity, event ID deduplication in Redis to prevent double processing, and timestamp comparison in the job handler for idempotency. The job queue pattern means we always respond within Shopify's 5-second window. As a safety net, I added a reconciliation cron that polls updated_at via GraphQL every 15 minutes."

Example 2: Embedded App Page (React + Polaris)

Pitch: "I built a product management interface in our embedded Shopify app using Polaris components and the GraphQL Admin API."

// app/routes/app.products.tsx (Remix)
import { json } from "@remix-run/node";
import { useLoaderData, useSubmit } from "@remix-run/react";
import { Page, Layout, Card, IndexTable, Badge, Button, Text } from "@shopify/polaris";

export async function loader({ request }) {
  const { admin } = await shopify.authenticate.admin(request);

  const response = await admin.graphql(`
    query GetProducts($first: Int!) {
      products(first: $first, sortKey: UPDATED_AT, reverse: true) {
        edges {
          node {
            id
            title
            status
            totalInventory
            featuredImage { url altText }
            metafield(namespace: "app", key: "sync_status") {
              value
            }
          }
        }
        pageInfo { hasNextPage endCursor }
      }
    }
  `, { variables: { first: 50 } });

  const { data } = await response.json();
  return json({ products: data.products.edges.map(e => e.node) });
}

export async function action({ request }) {
  const { admin } = await shopify.authenticate.admin(request);
  const formData = await request.formData();
  const productId = formData.get("productId");

  // Sync product to external service with idempotency
  const response = await admin.graphql(`
    mutation SetSyncStatus($id: ID!, $value: String!) {
      metafieldsSet(metafields: [{
        ownerId: $id
        namespace: "app"
        key: "sync_status"
        value: $value
        type: "single_line_text_field"
      }]) {
        userErrors { field message }
      }
    }
  `, { variables: { id: productId, value: "synced" } });

  return json({ success: true });
}

export default function ProductsPage() {
  const { products } = useLoaderData();
  const submit = useSubmit();

  return (
    <Page title="Product Sync Dashboard">
      <Layout>
        <Layout.Section>
          <Card padding="0">
            <IndexTable
              itemCount={products.length}
              headings={[
                { title: "Product" },
                { title: "Status" },
                { title: "Inventory" },
                { title: "Sync" },
                { title: "Actions" },
              ]}
              selectable={false}
            >
              {products.map((product) => (
                <IndexTable.Row key={product.id}>
                  <IndexTable.Cell>
                    <Text fontWeight="bold">{product.title}</Text>
                  </IndexTable.Cell>
                  <IndexTable.Cell>
                    <Badge tone={product.status === "ACTIVE" ? "success" : "warning"}>
                      {product.status}
                    </Badge>
                  </IndexTable.Cell>
                  <IndexTable.Cell>{product.totalInventory}</IndexTable.Cell>
                  <IndexTable.Cell>
                    <Badge tone={product.metafield?.value === "synced" ? "success" : "attention"}>
                      {product.metafield?.value || "pending"}
                    </Badge>
                  </IndexTable.Cell>
                  <IndexTable.Cell>
                    <Button size="slim" onClick={() =>
                      submit({ productId: product.id }, { method: "post" })
                    }>Sync Now</Button>
                  </IndexTable.Cell>
                </IndexTable.Row>
              ))}
            </IndexTable>
          </Card>
        </Layout.Section>
      </Layout>
    </Page>
  );
}

How to Walk Through This

🗣
Say: "I used Remix because it's Shopify's recommended template - server-side data loading via loaders means the GraphQL call happens on the backend where the access token lives. I chose Polaris's IndexTable for the product list because it matches the admin's native UI, reducing cognitive load for merchants. The sync status uses an app-owned metafield so it's scoped to our app and visible in the admin. For the action, I used Remix's form submission pattern rather than a raw fetch because it integrates with App Bridge navigation and handles loading states automatically."

Example 3: Dynamic Product Section (Liquid + JS)

Pitch: "I built a featured product section with real-time variant switching using the Section Rendering API."

<!-- sections/featured-product.liquid -->
<div class="featured-product section-{{ section.id }}">
  <div class="featured-product__media">
    {% assign current_variant = product.selected_or_first_available_variant %}
    {% if current_variant.featured_image %}
      {{ current_variant.featured_image | image_url: width: 800
        | image_tag:
          widths: '400,600,800',
          sizes: '(max-width: 768px) 100vw, 50vw',
          id: 'product-image',
          alt: product.title }}
    {% endif %}
  </div>

  <div class="featured-product__info">
    <h2>{{ product.title }}</h2>
    <div class="price" id="product-price">
      {% if current_variant.compare_at_price > current_variant.price %}
        <s>{{ current_variant.compare_at_price | money }}</s>
      {% endif %}
      {{ current_variant.price | money }}
    </div>

    <!-- Variant selector as Web Component -->
    <variant-selector data-section="{{ section.id }}">
      {% for option in product.options_with_values %}
        <fieldset>
          <legend>{{ option.name }}</legend>
          {% for value in option.values %}
            <label>
              <input type="radio" name="{{ option.name }}"
                value="{{ value }}"
                {% if option.selected_value == value %}checked{% endif %}>
              {{ value }}
            </label>
          {% endfor %}
        </fieldset>
      {% endfor %}
      <script type="application/json">{{ product.variants | json }}</script>
    </variant-selector>

    {% form 'product', product %}
      <input type="hidden" name="id" value="{{ current_variant.id }}">
      <button type="submit"
        {% unless current_variant.available %}disabled{% endunless %}>
        {% if current_variant.available %}
          {{ 'products.product.add_to_cart' | t }}
        {% else %}
          {{ 'products.product.sold_out' | t }}
        {% endif %}
      </button>
    {% endform %}
  </div>
</div>

{% style %}
  .section-{{ section.id }} { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
  @media (max-width: 768px) { .section-{{ section.id }} { grid-template-columns: 1fr; } }
{% endstyle %}

<script>
class VariantSelector extends HTMLElement {
  connectedCallback() {
    this.variants = JSON.parse(this.querySelector('script').textContent);
    this.section = this.dataset.section;
    this.addEventListener('change', () => this.onVariantChange());
  }

  onVariantChange() {
    const selected = [...this.querySelectorAll('fieldset')]
      .map(f => f.querySelector('input:checked')?.value);
    const variant = this.variants.find(v =>
      v.options.every((opt, i) => opt === selected[i])
    );
    if (!variant) return;

    // Update URL without reload
    const url = new URL(window.location);
    url.searchParams.set('variant', variant.id);
    history.replaceState({}, '', url);

    // Section Rendering API: get fresh server-rendered HTML
    fetch(`${url.pathname}?variant=${variant.id}&sections=${this.section}`)
      .then(r => r.json())
      .then(data => {
        const doc = new DOMParser().parseFromString(data[this.section], 'text/html');
        // Swap price and image from server-rendered HTML
        document.getElementById('product-price').innerHTML =
          doc.getElementById('product-price').innerHTML;
        document.getElementById('product-image').src =
          doc.getElementById('product-image').src;
        // Update form hidden input
        this.closest('section').querySelector('input[name="id"]').value = variant.id;
      });
  }
}
customElements.define('variant-selector', VariantSelector);
</script>

How to Walk Through This

🗣
Say: "I chose the Section Rendering API over client-side price formatting because it lets Shopify handle currency formatting, availability logic, and any Liquid-dependent data server-side. This means the variant switch shows the exact same HTML the server would render on a full page load - no formatting mismatches. I used a Web Component because it's Dawn's pattern - zero framework dependencies, works with any theme, and the browser natively handles element lifecycle. The variant data is output as JSON inline to avoid an extra API call. The section-scoped CSS via {% style %} prevents style leaks between sections."

22 Mock Code Review - Interviewer Questions

Here are the exact questions interviewers ask during a code walkthrough, with senior-level answers. Click each to reveal.

General / Opening Questions

"Walk me through this code. What problem does it solve?"

"What alternatives did you consider? Why did you choose this approach?"

"What would you change if you rebuilt this today?"

Architecture Questions

"How does this handle errors? What happens if the API is down?"

"How does this scale? What if a merchant has 100K products?"

"How do you handle rate limits?"

Code Quality Questions

"Why did you use this particular pattern / library / approach?"

"How do you test this code?"

"How do you ensure security in this Shopify app?"

Business Impact Questions

"How does this impact the merchant's business?"

"If you had one more week, what would you add?"

"Tell me about a bug or production incident related to this code."

Prep Checklist

Before the Interview

  • Pick one piece of code you know deeply - preferably your Laravel Shopify app
  • Have it open in an editor/IDE or ready to screen share
  • Practice the 2-minute pitch: problem, approach, result
  • Know 3 things you'd improve about the code
  • Prepare one failure story and what you learned
  • Know the numbers: how many merchants, products processed, response times
  • Practice explaining decisions in terms of business impact
  • Have the Shopify docs open in another tab for reference

25 Final Tips for Your Interview

What to Emphasize

  • Always talk about Online Store 2.0 as the modern approach
  • Mention Dawn as the reference theme
  • Show you understand server-side rendering
  • Talk about merchant experience - sections, blocks, customization
  • Performance: no jQuery, no frameworks, Web Components
  • Accessibility is not optional - WCAG 2.0 AA
  • Use Section Rendering API for dynamic updates

Common Mistakes to Avoid

  • Using {% include %} instead of {% render %}
  • Forgetting content_for_header or content_for_layout
  • Treating prices as dollars (they're in cents)
  • Lazy-loading above-the-fold images
  • Missing {{ block.shopify_attributes }}
  • Using enabled_on AND disabled_on together
  • Referring to Liquid templates when they mean JSON templates
  • Suggesting jQuery or heavy frameworks
Your Laravel Advantage You already understand MVC, templating (Blade ~ Liquid), routing, and Shopify's API. In the interview, bridge your knowledge: "Liquid is like Blade but server-rendered by Shopify. JSON templates are like route definitions pointing to section controllers. Sections are like Blade components with configurable props via schema."