Everything you need to pass a senior-level Shopify theme developer technical interview. Interactive diagrams, code examples, and practice questions.
Objects {{ }}, Tags {% %}, Filters | - the backbone of every Shopify theme
Layouts, templates, sections, blocks, snippets - the OS 2.0 way
JSON templates, section groups, app blocks - the modern approach
Lighthouse optimization, WCAG 2.0 AA, Web Vitals
Understanding the rendering pipeline is the #1 most important concept. Every interview question traces back to this.
product page typelayout/theme.liquid wraps everything. Contains {{ content_for_header }} (in <head>) and {{ content_for_layout }} (in <body>). These are REQUIRED.templates/product.json is loaded. It defines which sections to render and in what order. The JSON is pure data, no markup.sections/. Each has a {% schema %} tag defining its settings.{% render 'name' %}. All Liquid is resolved server-side."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.
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.
{{ }}Access and display data. Like Blade's {{ $variable }}
{{ product.title }}
{{ shop.name }}
{{ customer.first_name }}
{{ cart.total_price | money }}
{% %}Control flow & logic. Like Blade's @if / @foreach
{% if product.available %}
<button>Add to Cart</button>
{% else %}
<span>Sold Out</span>
{% endif %}
|Transform output. Chainable left-to-right. Like pipes in Unix.
{{ product.title | upcase }}
{{ product.price | money }}
{{ 'cart' | asset_url | script_tag }}
{{ 'now' | date: '%Y' }}
| Property | Type | Description |
|---|---|---|
product.id | Integer | Unique numeric ID |
product.title | String | Product name |
product.handle | String | URL-safe slug (used in URLs) |
product.description | String | Full HTML description |
product.price | Number | Price in cents (use | money filter) |
product.compare_at_price | Number | Original price (for sales) |
product.available | Boolean | Is any variant in stock? |
product.type | String | Product type |
product.vendor | String | Vendor/brand name |
product.tags | Array | Array of tag strings |
product.variants | Array | Array of variant objects |
product.options | Array | Option names: Size, Color, etc. |
product.options_with_values | Array | Options with all values |
product.images | Array | All product images |
product.featured_image | Image | First/main image |
product.selected_variant | Variant | Currently selected variant (from URL param) |
product.first_available_variant | Variant | First in-stock variant |
product.url | String | Relative URL: /products/handle |
product.metafields | Object | Access metafields: product.metafields.custom.key |
product.selected_or_first_available_variant | Variant | Best variant to show by default |
product.has_only_default_variant | Boolean | True if no options configured |
product.media | Array | All 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 %}
| Property | Description |
|---|---|
collection.title | Collection name |
collection.handle | URL slug |
collection.description | HTML description |
collection.products | Paginated array of products |
collection.products_count | Total product count |
collection.all_products_count | Count including unavailable |
collection.image | Collection image |
collection.url | Relative URL |
collection.sort_options | Available sort options |
collection.filters | Available storefront filters |
collection.current_type | Active product type filter |
collection.current_vendor | Active 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 %}
| Property | Description |
|---|---|
cart.items | Array of line items |
cart.item_count | Total quantity |
cart.total_price | Total in cents |
cart.total_discount | Total discounts |
cart.total_weight | Total weight in grams |
cart.note | Cart note from customer |
cart.attributes | Custom cart attributes |
cart.currency | Currency 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
Only available when logged in. Always check {% if customer %} first!
| Property | Description |
|---|---|
customer.first_name | First name |
customer.last_name | Last name |
customer.email | Email address |
customer.orders | Array of order objects |
customer.orders_count | Total orders |
customer.total_spent | Lifetime spend in cents |
customer.tags | Customer tags (for segmentation) |
customer.addresses | Saved addresses |
customer.default_address | Primary address |
| Property | Description |
|---|---|
shop.name | Store name |
shop.url | Store URL |
shop.email | Store email |
shop.currency | Default currency code |
shop.money_format | Money format string |
shop.description | Store description |
shop.locale | Current locale |
shop.metafields | Shop-level metafields |
| Property | Description |
|---|---|
request.locale | Current locale object |
request.page_type | Page type: product, collection, index, page, etc. |
request.host | Hostname |
request.path | URL path |
request.design_mode | True if in theme editor (critical for conditional logic!) |
| Object | Description |
|---|---|
settings | Theme settings from settings_schema.json |
routes | Store routes: routes.cart_url, routes.account_url, etc. |
page_title | SEO title for current page |
page_description | SEO meta description |
canonical_url | Canonical URL |
template | Current template name (e.g., "product", "collection") |
template.name | Template name |
template.suffix | Alternate template suffix |
content_for_header | Required in <head> - injects Shopify scripts |
content_for_layout | Required in <body> - renders template content |
all_products | Access any product: all_products['handle'] |
collections | Access any collection: collections['handle'] |
linklists | Navigation menus |
pages | Access pages by handle |
blogs | Access blogs by handle |
{# 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 %}
{# 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 %}
{# 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 %}
{% 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.
{{ 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
{{ '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 }}
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"
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: ', ' }}
{# 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 }}!"
}
}
{# 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' }}
{{ content_for_header }} in <head> and {{ content_for_layout }} in <body>. No subdirectories beyond the defined ones are supported.
<!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>
Sections are the building blocks of every Shopify page. Each section is a self-contained, configurable module.
<!-- 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 %}
{{ 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 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.
{% section 'name' %}// 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
}
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.
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 }}
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"
}
}
}
text, textarea, number, checkbox, radio, range, selectcolor, color_backgroundfont_pickercollection, product, blog, page, articleimage_picker, video, video_urlrichtext, inline_richtext, htmllink_list, urlmetaobject, metaobject_list, product_list, collection_listheader, paragraph{# 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 #}
{# 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 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.
The Ajax API provides REST endpoints for client-side cart operations. No authentication needed. Base URL: window.Shopify.routes.root
| Endpoint | Method | Purpose |
|---|---|---|
/cart.js | GET | Get full cart state |
/cart/add.js | POST | Add variant(s) to cart |
/cart/update.js | POST | Update quantities / attributes |
/cart/change.js | POST | Change specific line item |
/cart/clear.js | POST | Empty the cart |
/products/{handle}.js | GET | Fetch product JSON |
/recommendations/products.json | GET | Product recommendations |
/search/suggest.json | GET | Predictive search |
// 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'];
});
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.
Target: 60+ average Lighthouse score across home, product, and collection pages for Theme Store.
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.
Inline critical (above-fold) CSS in <head>. Use preload for render-blocking stylesheets. Avoid large frameworks. Use CSS custom properties for theming.
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.
Prefer system fonts (zero download). If web fonts: font-display: swap + preload critical files. Dawn defaults to system fonts.
Sort/filter BEFORE loops, not inside. Use {% liquid %} tag to reduce whitespace. Run shopify theme check for issues. Use Theme Inspector Chrome extension.
Max 2 preload hints per template. Host all assets on Shopify CDN (HTTP/2). Use preload_tag filter. Use stylesheet_tag: preload: true.
<!-- 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
}}
Shopify themes must meet WCAG 2.0 Level AA. This is tested during Theme Store review and increasingly asked in interviews.
All interactive elements must be keyboard-operable. Focus order = DOM order. Visible focus indicators required. Escape closes modals. No context changes on focus.
aria-expanded on collapsibles, aria-controls for hidden containers, aria-current for nav, aria-live for dynamic content (cart count), aria-describedby for errors.
Text <24px: 4.5:1 ratio. Large text: 3:1. Icons: 3:1. Input borders: 3:1. Color alone must never convey information.
<nav> for navigation, <main> for content, logical h1-h6, <label for="..."> on inputs. lang attribute on <html>.
Must have "Skip to content" link. Visible on focus. Main content uses tabindex="-1" to receive focus.
Minimum 44x44 CSS pixels for primary controls (buttons, menu links, cart controls, close buttons).
<!-- 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">
| Command | Purpose |
|---|---|
shopify theme init | Scaffold new theme (defaults to Skeleton theme) |
shopify theme dev | Local dev server at 127.0.0.1:9292 with hot reload |
shopify theme push | Upload local files to Shopify (overwrites remote) |
shopify theme pull | Download theme from Shopify |
shopify theme check | Lint with Theme Check (errors + best practices) |
shopify theme console | Interactive Liquid REPL against live data |
shopify theme publish | Make theme live |
shopify theme list | List all themes with IDs |
shopify theme package | Create ZIP for distribution |
shopify theme share | Upload with shareable preview link |
shopify theme profile | Profile Liquid execution (Speedscope) |
[environments.development]
store = "my-dev-store.myshopify.com"
theme = "123456789"
[environments.production]
store = "my-production-store.myshopify.com"
Theme app extensions let apps inject functionality into themes without modifying theme code.
target: "section""type": "@app" in blocks{% render block %}target: "head" or "body"{# 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 %}
checkout.liquid (Shopify Plus only) is being sunset. The modern approach is Checkout UI Extensions.
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.
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>
// 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 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);
{{ content_for_header }} in <head>
{{ content_for_layout }} in <body>
{% render 'snippet-name', var: value %}
{% sections 'header-group' %}
{% form 'product', product %}...{% endform %}
{{ img | image_url: width: 800 | image_tag: loading: 'lazy' }}
{{ product.price | money }}
{{ 'key.path' | t }}
{% paginate collection.products by 12 %}...{% endpaginate %}
{% if request.design_mode %}...{% endif %}
{% style %} .section-{{ section.id }} { } {% endstyle %}
{{ 'file.css' | asset_url | stylesheet_tag }}
POST /cart/add.js { items: [{ id: variantId, quantity: 1 }] }
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?
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!
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 %}."
image_url and image_tag filters for responsive images with lazy loadingmoney filterproduct.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">.
<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>
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."
{% schema %} with name, settings, blocks, and presetsenabled_on to restrict to index template{{ block.shopify_attributes }} on block wrappers{% style %}{% 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".
<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 %}
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."
HTMLElementconnectedCallback to set up event listeners/cart/add.js with FormDatasections parameter for Section Rendering APIcustomElements.define()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).
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);
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."
sections and orderblock_ordertheme layout"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.
{
"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"]
}
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."
{% paginate collection.products by 12 %}collection.filters for filter UI{% render 'product-card' %}{{ paginate | default_pagination }}{% 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 %}.
{% 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 }} ×
</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 %}
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."
HTMLElementhistory.replaceState?variant=ID§ions=main-productJSON.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).
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);
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."
role="dialog" and aria-modal="true"aria-label or aria-labelledby on the draweraria-live region for cart count updatesaria-labelrole="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.
<!-- 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">
×
</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>
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."
{{ content_for_header }} in <head> and {{ content_for_layout }} in <body>lang attribute using request.localepage_title, meta description, canonical URLrole="main" and tabindex="-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 }}.
<!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>
You already build Shopify apps with Laravel. Here's the architecture knowledge a senior dev must articulate in an interview.
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.
Single store. Created via Partner Dashboard or Admin. Can use admin-generated tokens or OAuth. Functions require Shopify Plus.
Deprecated Jan 2022 Auto-converted to custom apps. Used basic HTTP auth. If you see these in legacy systems, migrate immediately.
// 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"
}
| Extension | Where | Language | Key Detail |
|---|---|---|---|
| Theme App Extension | Storefront | Liquid + CSS/JS | App blocks in sections, app embeds globally |
| Checkout UI Extension | Checkout | Preact/JSX | 64KB limit, Plus-only for payment steps |
| Admin UI Extension | Admin panels | Preact/JSX | Blocks, actions, selection actions on resources |
| Shopify Functions | Server-side (edge) | Rust/JS → Wasm | <5ms, no network, 11M instruction limit |
Every app must implement these 3 webhooks or it gets rejected from the App Store:
| Topic | Trigger | Action Required |
|---|---|---|
customers/data_request | Customer requests their data | Provide all stored data within 30 days |
customers/redact | Customer requests deletion | Delete customer data within 30 days |
shop/redact | 48h after app uninstall | Erase ALL store data within 30 days |
"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.
Leaky bucket based on query cost, not request count:
| Plan | Bucket Size | Restore Rate |
|---|---|---|
| Standard | 1,000 points | 50 pts/sec |
| Advanced | 2,000 points | 100 pts/sec |
| Plus | 10,000 points | 500 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
orders/create fires when a new order is placedX-Shopify-Hmac-SHA256 = HMAC-SHA256 of raw body with client secretX-Shopify-Event-Id header. Same ID on retries. Store in Redis with TTL.| Header | Purpose |
|---|---|
X-Shopify-Hmac-SHA256 | HMAC signature (verify authenticity) |
X-Shopify-Topic | Event type: orders/create |
X-Shopify-Event-Id | Unique per event (use for deduplication) |
X-Shopify-Triggered-At | Timestamp of event (use for ordering) |
X-Shopify-Shop-Domain | Store domain |
Retry policy: 8 retries over 4 hours. After 8 failures: subscription auto-deleted. Delivery: at-least-once, no ordering guarantee.
// 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
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
| Need | Solution | Why |
|---|---|---|
| Custom field on product/order | Metafield (app-owned) | Renders in Liquid, visible in admin, filterable |
| New custom entity | Metaobject | Like a custom DB table in Shopify, gets URL routing |
| Per-install config | App-data metafield | Hidden from admin, scoped to your app |
| High-volume data | External DB (Postgres) | Complex queries, joins, no API rate limits |
| Sensitive credentials | External encrypted storage | Never store secrets in metafields |
// 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
Respond 200 instantly. Push to Redis/SQS queue. Worker processes async. Shopify's 5-second timeout means no heavy processing inline.
On 429 (rate limited): wait 2^n seconds. Monitor extensions.cost.throttleStatus in every GraphQL response to predict limits.
If Shopify API is down, serve cached data. Never let API failures break the merchant's storefront. Cache metafields locally.
Webhooks can be lost. Poll with updated_at filter every 5-15 min. Use bulk operations for full sync weekly.
Senior engineers don't just pick technologies - they explain why in terms of business impact.
Business value: Embedded = lower merchant friction, higher retention. Merchants don't leave the admin. "Built for Shopify" badge increases trust and install rate.
| Factor | Webhooks | Polling |
|---|---|---|
| Latency | Near real-time | Delayed (interval-based) |
| Reliability | Can lose events | Catches everything eventually |
| API cost | Zero (push-based) | Consumes rate limit budget |
| Ordering | Not guaranteed | You 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."
| Factor | Metafields | External DB |
|---|---|---|
| Access in Liquid | Direct: product.metafields.app.key | Need app proxy or API call |
| Admin visibility | Visible to merchants | Hidden |
| Query complexity | Limited (no joins) | Full SQL power |
| Rate limits | Counts against API limits | Your own limits |
| Data volume | Good for small-medium | Unlimited |
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."
| Factor | Theme App Extension | ScriptTag |
|---|---|---|
| Theme editor | Drag-and-drop by merchant | Invisible, injected |
| Performance | Assets on Shopify CDN | External script, blocks rendering |
| Uninstall cleanup | Automatic | May leave orphaned scripts |
| OS 2.0 themes | Full support | Works 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.
| Factor | Shopify Functions | Custom Backend |
|---|---|---|
| Execution | Shopify edge, <5ms | Your server, variable latency |
| Reliability | Shopify manages uptime | You manage uptime |
| Flexibility | No network, no state, 11M instructions | Full flexibility |
| Use cases | Discounts, payment, delivery, cart | Everything 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."
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.
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?
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.
Can you articulate the business problem clearly? Do you understand the merchant's pain point, not just the technical requirement?
Why did you pick this architecture? What alternatives did you consider? Why NOT the other options? This shows depth.
Can you point to patterns you're proud of AND things you'd improve? Self-awareness > perfection. Seniors know their code's weaknesses.
How did this help the merchant? Conversion, performance, reliability, developer velocity? Tie every technical choice to a business outcome.
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.
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']]
);
}
}
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>
);
}
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}§ions=${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>
{% style %} prevents style leaks between sections."
Here are the exact questions interviewers ask during a code walkthrough, with senior-level answers. Click each to reveal.
{% include %} instead of {% render %}content_for_header or content_for_layout{{ block.shopify_attributes }}enabled_on AND disabled_on together