MODRACXKENNETH D'SILVA

← Archive & Insights

Shopify Coding Standards: Theme & App Development Best Practices

A theme I inherited recently had 47 Liquid for loops on the collection page, an 8-second Largest Contentful Paint on mobile, and a `| json` filter piping customer data directly into the DOM — an XSS incident waiting to happen.

By Kenneth D'SilvaReading Time: 45 min readCategory: Architecture & Cloud

1. The Chaos of Unstandardised Shopify Code

When a merchant's store processes millions in annual revenue, the infrastructure supporting it must be treated like a serious engineering project. Unfortunately, the Shopify ecosystem is notorious for unstandardised, poorly structured code. Because Liquid is a forgiving templating language and the platform abstracts away the database scaling, developers often ignore algorithmic complexity, resource management, and security fundamentals.

The incident I described in the lede is not an anomaly. I frequently encounter monolithic themes with 10,000-line theme.liquid files, apps that silently retry failed webhooks thousands of times, and UI components built on deprecated jQuery plugins from 2014. These practices result in stores that buckle under peak traffic, leak customer data, and take minutes to compile CSS. It is a dereliction of professional duty to deliver such code to a client.

In this guide, I will detail the exact technical standards required to build performant, secure, and maintainable Shopify themes and applications. Whether you are migrating to a headless setup or optimising a standard Dawn-based theme, these principles apply. We will move systematically from the rendering pipeline, through theme structure, and into app architecture. If you want a deeper dive on app design patterns, consult my guide on custom Shopify app development.

2. The Liquid Rendering Pipeline

To write efficient Shopify themes, you must understand how Shopify renders a page request. When a user requests a URL, the request first hits the Cloudflare CDN edge. If the page is cached and valid, the edge serves the response immediately. However, if the cache is missed (e.g., due to dynamic query parameters, an active customer session, or a recent product update), the request is routed to Shopify's application servers.

At the app server layer, the Liquid template is loaded from the theme. Liquid is executed server-side via Shopify's Ruby-based Liquid engine. This engine compiles the tags, filters, and objects into a final HTML document before it is sent back across the wire. This means Liquid is entirely synchronous. The browser cannot begin parsing HTML or downloading CSS until the Liquid engine finishes its execution.

The Cost of Liquid Operations

Understanding the computational cost of different Liquid operations is essential for preventing bottlenecks. Object property access (e.g., {{ product.title }}) is exceptionally cheap. Shopify's backend models are highly optimised for these read operations. However, for loops over collections are expensive. Each iteration of a loop requires the engine to resolve a Liquid drop (an object exposing specific attributes) and evaluate its properties in memory. Looping over 1,000 products on a single page will visibly delay Time to First Byte (TTFB).

Before and After: Fixing the N+1 Loop

One of the most destructive patterns I encounter is the unpaginated loop over a massive collection. Let's look at a concrete example of this anti-pattern and how to resolve it using cursor pagination.

Before: The Performance Killer


{%- comment -%}
  ANTI-PATTERN: Unpaginated looping over potentially thousands of items.
  This forces the Liquid engine to instantiate drops for every product,
  crashing render times and potentially exceeding memory limits.
{%- endcomment -%}
<div class="product-grid">
  {% for product in collections.all.products limit: 250 %}
    {% render 'product-card', product: product %}
  {% endfor %}
</div>
        

In a hypothetical Liquid profiler run, this unpaginated loop across 250 items resulted in a total template render time of 47ms. While 47ms might sound fast in absolute terms, server response budgets are incredibly tight, and that's 47ms spent rendering a single block of HTML before CSS, JS, or images even begin to download.

After: Cursor Pagination


{%- comment -%}
  CORRECT: Paginate limits the in-memory object resolution to exactly the page size.
  Cursor pagination allows for predictable memory consumption.
{%- endcomment -%}
{% paginate collections.all.products by 24 %}
  <div class="product-grid">
    {% for product in collections.all.products %}
      {% render 'product-card', product: product %}
    {% endfor %}
  </div>
  
  {% if paginate.pages > 1 %}
    {{ paginate | default_pagination }}
  {% endif %}
{% endpaginate %}
        

By wrapping the collection query in a paginate block, we strictly bound the loop. The same layout now renders only 24 items per request. In our profiler, this reduces the block render time to 8ms. That is an 83% reduction in server-side processing for this section alone.

One of the most consequential decisions you will make in theme development is choosing between the include and render tags. The older include tag allows the included snippet to access all variables from the parent scope. This leaks state, makes dependencies untraceable, and severely limits the engine's ability to cache the snippet output.

The modern standard is the render tag. When you use render, the snippet operates in an isolated scope. It cannot access variables from the outer file unless you explicitly pass them. This is a feature, not a bug. It forces you to write pure, reusable UI components.


{%- comment -%}
  INCORRECT: Leaks scope, slow parsing.
{%- endcomment -%}
{% include 'product-card' %}

{%- comment -%}
  CORRECT: Isolated scope, strict parameter passing.
{%- endcomment -%}
{% render 'product-card', product: product, show_vendor: section.settings.show_vendor %}
        

Always prefer the {% liquid %} block over chaining multiple single {% assign %} tags. It clutters the template and makes managing whitespace characters (-) a nightmare. Use the {% liquid %} block for any multi-statement logic. It improves readability and removes the risk of inadvertently injecting hundreds of blank lines into your HTML output.

Output Escaping and XSS Prevention

Never trust data entered by users. Any variable containing user input, particularly from customer accounts or cart notes, must be piped through the | escape filter. Passing customer names or order notes through | json directly into a script block exposes the store to cross-site scripting (XSS) attacks. If a malicious user sets their name to a script tag, your unescaped output will execute it for anyone viewing their data (including the admin in some poorly built apps).

3. Theme Performance Budgeting and Measurement

You cannot optimise what you do not measure. Establishing a strict performance budget and integrating observability into your theme's frontend architecture is paramount. When auditing poorly performing Shopify storefronts, I rely heavily on browser-native tooling to expose the true cost of unoptimised themes.

Chrome DevTools Performance Panel Walkthrough

The Chrome DevTools Performance panel is the ultimate source of truth. When auditing a Shopify store:

  1. Open DevTools, navigate to the Performance tab, and enable "Web Vitals" in the recording settings.
  2. Throttling is critical: set CPU throttling to 4x slowdown and Network to Fast 3G to simulate real-world mobile devices.
  3. Record a page reload.
  4. Inspect the Main Thread timeline. Look for dense blocks of yellow (scripting). In legacy Shopify themes, you will often find massive parse/compile chunks dedicated to un-tree-shaken jQuery plugins or monolithic tracking scripts.

Measuring LCP with PerformanceObserver

While Lighthouse provides synthetic lab data, you need real user monitoring (RUM) in production. You can natively instrument LCP tracking using the PerformanceObserver API within your theme:


try {
  const observer = new PerformanceObserver((entryList) => {
    const entries = entryList.getEntries();
    const lastEntry = entries[entries.length - 1];
    
    // Log the LCP value in milliseconds
    console.log(`LCP Element:`, lastEntry.element);
    console.log(`LCP Time: ${lastEntry.startTime.toFixed(2)}ms`);
    
    // Optionally beacon this data to an analytics endpoint
    if (lastEntry.startTime > 2500) {
      console.warn("LCP budget exceeded!");
    }
  });

  observer.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {
  // Browser does not support PerformanceObserver
}
        

Shopify's Built-in Speed Score

Merchants frequently obsess over the Shopify Speed Score visible in the admin dashboard. It is vital to understand how this is calculated. The score is not a real-time reflection of your live store; it is an aggregated metric based on Google Lighthouse audits run across your store's homepage, highly trafficked collection pages, and product pages over a trailing multi-day window. It is weighted against stores in a similar industry. A drop in the score usually correlates with the addition of a heavy third-party app script or unoptimised hero media.

The 3 Most Common Shopify Theme Performance Regressions

When conducting performance audits, I encounter the same three architectural failures repeatedly:

  1. Large hero images without preload: The LCP image is relying on standard browser discovery, delaying download until parsing reaches the `img` tag. This is solved by the `preload` link injection discussed below.
  2. jQuery loaded synchronously from a third-party app: Apps injecting legacy jQuery blocks the main thread completely. If an app relies on synchronous jQuery in 2026, uninstall it and find a modern alternative or build the feature natively.
  3. CSS from blocking <link> tags in the body: Stylesheets should reside in the <head>. Placing them midway through the document forces the browser to halt rendering, fetch the CSS, recalculate the style tree, and repaint the screen.

4. Section Schema Design Patterns

Hardcoding content is a failure of theme architecture. All customisable content must use section schemas. If a merchant cannot edit a heading, a button label, or a colour choice via the Theme Editor, the implementation is flawed. Construct reusable, modular sections with defined block limits to prevent merchants from accidentally destroying the layout.

A production-quality section schema should be comprehensive. It must declare its name, an optional class to wrap the section element, and an array of settings. It should also utilise a blocks array if repeatable content is needed, bounded by a max_blocks parameter to prevent performance degradation from infinite loops. Finally, if the section is meant to be dynamically added by a merchant, it must include a presets array.

Here is a complete, standard-compliant section schema demonstrating multiple input types:


{% schema %}
{
  "name": "Featured Promotion",
  "tag": "section",
  "class": "section-promotion",
  "settings": [
    {
      "type": "color",
      "id": "background_color",
      "label": "Background Colour",
      "default": "#f4f4f4"
    },
    {
      "type": "image_picker",
      "id": "hero_image",
      "label": "Promotion Image"
    },
    {
      "type": "range",
      "id": "padding_top",
      "min": 0,
      "max": 100,
      "step": 4,
      "unit": "px",
      "label": "Top Padding",
      "default": 36
    },
    {
      "type": "select",
      "id": "text_alignment",
      "label": "Text Alignment",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Centre" },
        { "value": "right", "label": "Right" }
      ],
      "default": "center"
    },
    {
      "type": "checkbox",
      "id": "enable_animation",
      "label": "Enable fade-in animation",
      "default": true
    }
  ],
  "max_blocks": 6,
  "blocks": [
    {
      "type": "heading",
      "name": "Heading",
      "limit": 1,
      "settings": [
        {
          "type": "text",
          "id": "title",
          "label": "Heading Text",
          "default": "Special Offer"
        }
      ]
    },
    {
      "type": "link",
      "name": "Button",
      "limit": 2,
      "settings": [
        {
          "type": "url",
          "id": "button_link",
          "label": "Button Link"
        },
        {
          "type": "text",
          "id": "button_label",
          "label": "Button Label",
          "default": "Shop Now"
        }
      ]
    }
  ],
  "presets": [
    {
      "name": "Featured Promotion",
      "blocks": [
        { "type": "heading" },
        { "type": "link" }
      ]
    }
  ]
}
{% endschema %}
        

It is critical to understand the return value of each setting type in Liquid. A color setting returns a CSS hex string (e.g., #ff0000). An image_picker returns a full Liquid image object, which must then be passed through the image_url filter. A range setting returns an integer. A richtext setting returns an HTML string wrapped in paragraph tags, meaning you should not wrap it in additional <p> tags in your template. Product and collection settings return drop objects representing the selected resource, giving you access to all their respective properties.

5. App Security Standards

When building Shopify applications, security is not an afterthought; it is an infrastructural prerequisite. Shopify delegates the responsibility of securing merchant data entirely to the app developer. Failing to adhere to rigorous security standards will result in application delisting and potential liability.

HMAC Verification Code in Full

As mentioned earlier, webhook idempotency is vital, but the foundational layer of webhook processing is HMAC (Hash-based Message Authentication Code) validation. You must ensure that every incoming payload originates from Shopify and has not been tampered with.

The following is the complete, secure Node.js middleware pattern for validating the raw body buffer using a timing-safe equality check. Note that you must bypass standard JSON parsing middleware for webhook routes to access the raw buffer.


const crypto = require('crypto');

/**
 * Middleware to verify Shopify Webhook HMAC
 */
function verifyShopifyWebhook(req, res, next) {
  const hmacHeader = req.get('X-Shopify-Hmac-Sha256');
  
  if (!hmacHeader) {
    return res.status(401).send('Missing HMAC header');
  }

  // Generate the hash using the app's client secret and the raw request body buffer
  const generatedHash = crypto
    .createHmac('sha256', process.env.SHOPIFY_CLIENT_SECRET)
    .update(req.body, 'utf8', 'hex')
    .digest('base64');
    
  let hashEquals = false;
  
  try {
    // timingSafeEqual prevents timing attacks where an attacker measures
    // the time it takes to compare strings to deduce the secret.
    hashEquals = crypto.timingSafeEqual(
      Buffer.from(generatedHash),
      Buffer.from(hmacHeader)
    );
  } catch (e) {
    // Catch buffer length mismatch errors
    hashEquals = false;
  }

  if (!hashEquals) {
    console.error(`HMAC validation failed for shop: ${req.get('X-Shopify-Shop-Domain')}`);
    return res.status(401).send('HMAC validation failed');
  }

  next();
}
        

Content Security Policy (CSP) for Embedded Apps

Embedded apps render inside an iframe within the Shopify admin dashboard. To protect merchants from clickjacking attacks, you must enforce a strict Content Security Policy. Shopify requires that your app sets the frame-ancestors directive correctly.

Your server must respond with a header that explicitly limits framing to the specific shop's admin domain (or admin.shopify.com).


// Express middleware for setting CSP
app.use((req, res, next) => {
  const shop = req.query.shop || req.headers['x-shopify-shop-domain'];
  
  if (shop) {
    // Restrict iframing to the specific merchant's admin dashboard
    res.setHeader(
      'Content-Security-Policy',
      `frame-ancestors https://${shop} https://admin.shopify.com;`
    );
  } else {
    // If shop is not present, completely deny framing
    res.setHeader('Content-Security-Policy', "frame-ancestors 'none';");
  }
  
  next();
});
        

OAuth Token Rotation

Access tokens provided by Shopify should never be considered permanent. Shopify encourages token rotation strategies to mitigate the risk of exposed secrets. While older apps relied on indefinite offline access tokens, modern apps utilize online session tokens that expire. You must build retry logic into your API client that detects a 401 Unauthorized response, seamlessly triggers a re-authentication flow via App Bridge, and transparently retries the failed request with the new token.

6. JavaScript Standards for Themes

A common failure in Shopify frontend development is the importation of massive, generic frameworks. Themes do not need Bootstrap, Tailwind (unless rigorously tree-shaken in a build step), or jQuery. The native platform APIs are more than capable.

Shopify's official reference theme, Dawn, established the standard of using native Web Components. You should author custom elements by extending HTMLElement and registering them via customElements.define(). This approach is superior to jQuery or generic vanilla JS selectors for several reasons: it requires zero external dependencies, provides encapsulated DOM scope, and leverages built-in lifecycle hooks. When a merchant adds a section in the Theme Editor, the browser automatically instantiates the web component when it enters the DOM. You no longer need to write complex mutation observers to re-initialise scripts.

Constructing a Custom Element

Here is a complete custom element for a cart notification drawer:


class CartNotification extends HTMLElement {
  constructor() {
    super();
    this.notification = this.querySelector('.cart-notification');
    this.closeButton = this.querySelector('.cart-notification__close');
    this.header = document.querySelector('sticky-header');
  }

  connectedCallback() {
    if (this.closeButton) {
      this.closeButton.addEventListener('click', this.close.bind(this));
    }
    // Bind global event emitted when an item is added to cart
    document.addEventListener('cart:added', this.open.bind(this));
  }

  disconnectedCallback() {
    document.removeEventListener('cart:added', this.open.bind(this));
  }

  open() {
    this.notification.classList.add('is-active');
    this.notification.setAttribute('aria-hidden', 'false');
    if (this.header) this.header.reveal();
  }

  close() {
    this.notification.classList.remove('is-active');
    this.notification.setAttribute('aria-hidden', 'true');
  }
}

customElements.define('cart-notification', CartNotification);
        

Notice the use of connectedCallback and disconnectedCallback. These hooks ensure event listeners are bound and unbound correctly, preventing memory leaks if the element is removed.

Optimising Script Execution

Never block the main thread. Understanding execution timing is critical. Use DOMContentLoaded when a script must run as soon as the HTML is parsed, but before images finish loading. Use the load event for logic dependent on external assets (like calculating the exact height of a loaded image).

For non-critical tasks—such as initialising analytics, tracking user scroll depth, or pre-fetching predictive search data—use requestIdleCallback. This native API instructs the browser to execute your callback only when the main thread is idle, ensuring that user interactions like scrolling and clicking remain completely fluid.

Additionally, implement code splitting in your themes. Instead of bundling 500KB of JavaScript into a single theme.js file, use dynamic import() statements to load modules only when they are needed. If a user never opens the 3D product model viewer, they should never download the script that powers it.

7. Shopify App Development Standards Deep-Dive

Shopify app development has shifted heavily toward the Remix framework and Node.js. If you are building a modern app, you must adhere to strict security and performance baselines. Remix provides a phenomenal data flow architecture that solves many of the traditional complexities of embedded single-page applications.

In Remix, data fetching occurs in loader functions that run exclusively on the server. This is where you authenticate the request using Shopify's authenticate.admin() utility, securely interacting with the GraphQL API without exposing your access tokens to the client. Form submissions and data mutations are handled by action functions, which also execute server-side.

For client-side interactivity without triggering a full page reload, Remix provides the useFetcher hook. This is ideal for updating inventory quantities or toggling product statuses inline.

A Complete Remix Route File

Below is a production-grade Remix route file for a product sync dashboard. It demonstrates the loader, the action, and the component, fully integrating Polaris components.


import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useFetcher, ErrorBoundary as RemixErrorBoundary } from "@remix-run/react";
import { Page, Layout, Card, Button, DataTable, Text } from "@shopify/polaris";
import { authenticate } from "../shopify.server";

// 1. Server-side Data Loading
export async function loader({ request }: LoaderFunctionArgs) {
  const { admin, session } = await authenticate.admin(request);
  
  if (!session) {
    throw new Error("Unauthorized Access");
  }

  const response = await admin.graphql(`
    query GetProducts {
      products(first: 5) {
        edges {
          node {
            id
            title
            status
          }
        }
      }
    }
  `);
  
  const data = await response.json();
  return json({ products: data.data.products.edges });
}

// 2. Server-side Mutation Handling
export async function action({ request }: ActionFunctionArgs) {
  const { admin } = await authenticate.admin(request);
  const formData = await request.formData();
  const productId = formData.get("productId") as string;
  const newStatus = formData.get("status") as string;

  const response = await admin.graphql(`
    mutation UpdateProductStatus($input: ProductInput!) {
      productUpdate(input: $input) {
        product {
          id
          status
        }
        userErrors {
          field
          message
        }
      }
    }
  `, {
    variables: {
      input: {
        id: productId,
        status: newStatus
      }
    }
  });

  const data = await response.json();
  
  if (data.data?.productUpdate?.userErrors?.length > 0) {
    return json({ success: false, errors: data.data.productUpdate.userErrors }, { status: 400 });
  }

  return json({ success: true, product: data.data.productUpdate.product });
}

// 3. Client-side Component
export default function ProductSync() {
  const { products } = useLoaderData();
  const fetcher = useFetcher();

  const rows = products.map(({ node }: any) => [
    node.title,
    node.status,
    
      
      
      
    
  ]);

  return (
    
      
        
          
            
          
        
      
    
  );
}

// 4. Error Boundary for this route
export function ErrorBoundary() {
  return (
    
      
        
          An error occurred while loading the product sync dashboard. Please verify your API permissions.
        
      
    
  );
}
        

Notice the ErrorBoundary export. Remix uses React Error Boundaries natively. If the loader fails or the component throws an exception, Remix catches it and renders this fallback UI instead of crashing the entire application iframe.

8. GraphQL Admin API Patterns

Use the GraphQL Admin API for all new development. REST is maintained but heavily de-prioritised by Shopify. GraphQL prevents over-fetching and allows you to retrieve complex related data in a single request.

However, GraphQL requires a fundamentally different approach to rate limiting. Shopify does not use a simple request-per-second limit. Instead, it uses a calculated cost metric. Every request returns an extensions object containing the actualQueryCost and a throttleStatus object detailing the currentlyAvailable points and the restoreRate.

When executing bulk operations, you must implement a cost-aware retry wrapper. If the currentlyAvailable budget drops below the anticipated cost of your next query, you calculate the required wait time by dividing the deficit by the restoreRate, wait for that duration, and then retry.

TypeScript Implementation of Cost-Aware Retries with Jitter

Here is a robust implementation of a cost-aware fetcher that implements exponential backoff with jitter to prevent thundering herd scenarios across multiple workers.


interface ThrottleStatus {
  maximumAvailable: number;
  currentlyAvailable: number;
  restoreRate: number;
}

/**
 * Executes a GraphQL query against the Shopify Admin API with cost-aware retries.
 * Implements exponential backoff and jitter to gracefully handle throttling.
 */
export async function executeCostAwareQuery(admin: any, query: string, variables: any = {}, estimatedCost: number = 50) {
  let attempt = 0;
  const maxAttempts = 6;
  const baseDelayMs = 500;

  while (attempt < maxAttempts) {
    const response = await admin.graphql(query, { variables });
    const data = await response.json();

    // Check for throttling error
    if (data.errors && data.errors[0]?.extensions?.code === 'THROTTLED') {
      const throttleStatus = data.errors[0].extensions.cost.throttleStatus as ThrottleStatus;
      const pointsNeeded = estimatedCost - throttleStatus.currentlyAvailable;
      
      // Calculate delay based on restore rate
      let delayMs = Math.ceil(pointsNeeded / throttleStatus.restoreRate) * 1000;
      
      // Add exponential backoff and jitter
      const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.floor(Math.random() * 200);
      delayMs = Math.max(delayMs, exponentialDelay) + jitter;
      
      console.warn(`[API] Rate limited. Points needed: ${pointsNeeded}. Waiting ${delayMs}ms before attempt ${attempt + 1}...`);
      await new Promise(resolve => setTimeout(resolve, delayMs));
      
      attempt++;
      continue;
    }

    // Log the actual cost for future optimisation and monitoring
    const actualCost = data.extensions?.cost?.actualQueryCost;
    const available = data.extensions?.cost?.throttleStatus?.currentlyAvailable;
    
    console.info(`[API] Query executed. Cost: ${actualCost}. Available budget: ${available}.`);
    
    if (actualCost > 800) {
      console.warn(`[API] High cost query detected: ${actualCost} points. Consider paginating smaller chunks.`);
    }

    return data;
  }
  
  throw new Error(`Failed to execute GraphQL query after ${maxAttempts} retry attempts.`);
}
        

9. Metafield Standards

Metafields are the primary mechanism for extending Shopify's data model. The system relies on three identifiers: the namespace (used for grouping related fields), the key, and the type.

Crucially, to access a metafield via Liquid (e.g., {{ product.metafields.custom.sizing_guide }}), you must create a Metafield Definition. Unstructured metafields cannot be exposed to the storefront directly without a definition. The types are strongly enforced: single_line_text_field, multi_line_text_field, integer, json, file_reference, product_reference, and url are common examples.

Null-Safe Access in Liquid

When outputting metafields in a theme, you must practice defensive programming. Never assume a metafield exists. Always use the != blank check to ensure you do not output empty HTML wrappers.


{% if product.metafields.custom.sizing_guide != blank %}
  <div class="product-sizing-guide">
    {{ product.metafields.custom.sizing_guide | metafield_tag }}
  </div>
{% endif %}
        

Creating a Definition via GraphQL

If your app relies on specific metafields, you should create the definitions programmatically upon installation:


mutation CreateMetafieldDefinition {
  metafieldDefinitionCreate(definition: {
    namespace: "custom",
    key: "sizing_guide",
    name: "Sizing Guide",
    type: "single_line_text_field",
    ownerType: PRODUCT
  }) {
    createdDefinition {
      id
    }
    userErrors {
      message
    }
  }
}
        

10. Theme Testing and Code Review

Before any theme code is merged to the main branch, it must pass rigorous automated and manual checks. Shopify provides the Theme Check CLI (shopify theme check) precisely for this purpose.

Theme Check acts as a linter for Liquid. It identifies missing schema translations, deprecated Liquid filters (like img_url), missing alt attributes on images, syntax errors, and structural issues. You can configure rules globally by creating a .theme-check.yml file in your theme root. If a specific file requires a rule to be disabled (for example, intentionally bypassing an N+1 check for a highly specific layout), you can disable it via inline comments.

You must integrate Theme Check into your Continuous Integration (CI) pipeline. A standard GitHub Actions workflow should trigger shopify theme check on every pull request. If the check fails, the PR cannot be merged.

Beyond automated tooling, manual accessibility testing is non-negotiable. Dawn is engineered to meet WCAG 2.1 AA standards. If you add custom components, you must ensure they preserve this baseline. This means manually navigating your store using only the keyboard (Tab, Shift+Tab, Enter, Space). Ensure focus rings are visible, modal dialogs trap focus, and ARIA attributes (like aria-expanded and aria-hidden) are correctly toggled by your JavaScript.

11. Polaris Design System in Apps

When building embedded apps for the Shopify admin, you are mandated to use Polaris, Shopify's official React component library. Polaris ensures that your application feels like a native extension of the administrative interface.

The root of your app must be wrapped in an AppProvider, which handles internationalisation (i18n) and theme context. Structurally, every primary view should utilize the Page component, configuring its primary actions and breadcrumbs. Note that in Polaris v12, the legacy Card component's layout responsibilities are being replaced by the highly composable Box component, allowing for more granular control over spacing and borders.

For displaying data, do not build custom HTML tables. Use the DataTable component for simple, non-interactive reporting grids. When listing clickable entities (like orders or products), use the ResourceList with nested ResourceItem components. For highly interactive data tables requiring sorting, bulk selection, and filtering, utilize the IndexTable component.

Failing to use Polaris UI components in embedded apps creates a jarring user experience. Merchants expect your app to look and behave exactly like Shopify's native settings pages. Applications that roll their own disparate design systems will routinely fail the Built for Shopify UI review.

12. The Built for Shopify Imperative

Shopify strongly pushes apps to meet their "Built for Shopify" (BFS) criteria. Apps that meet BFS standards receive higher algorithmic placement in the App Store, an elevated trust badge, and reduced revenue share fees. Meeting these requirements means passing strict Core Web Vitals checks, using App Bridge seamlessly, and ensuring uninstalls leave zero residual code in the merchant's theme. For a full breakdown of this topic, read my complete Built for Shopify Guide.

13. When Not to Over-Engineer: The Honest Trade-Off

Standards are vital, but blind adherence to architectural purity can destroy budgets. Not every project requires a headless setup, automated CI/CD pipelines, and rigorous Web Component encapsulation.

When to relax the rules: If you are building a small, one-off store with 10 products, an internal testing sandbox, or a rapid prototype to validate product-market fit, strict adherence to enterprise theme standards is a waste of time. In these scenarios, using a pre-built theme, accepting a slightly lower Lighthouse score, and skipping comprehensive automated testing is a valid business decision. The goal of a prototype is speed to market, not architectural perfection. Over-engineering a small build will deplete the client's budget before they make their first sale.

14. FAQ: Shopify Architecture

Why is the {% liquid %} tag better than multiple inline tags?

The {% liquid %} tag allows you to write multiple variable assignments and logic conditions without cluttering your code with repetitive tags and whitespace strip indicators. It significantly improves readability and reduces parsing overhead in large theme files.

How do I prevent XSS when outputting customer data in Liquid?

Always pass user-supplied data through the | escape filter. Never use | raw or | json to output user data directly into DOM elements without sanitising it, as this opens the door to cross-site scripting vulnerabilities.

Why should I use the GraphQL Admin API instead of REST?

GraphQL allows you to fetch exactly the data you need in a single request, reducing bandwidth and improving processing speed. Shopify is also focusing its future feature development on the GraphQL API, meaning REST endpoints will inevitably lag behind.

Why is Polaris necessary for Shopify apps?

Polaris ensures that your embedded application feels like a native extension of the Shopify admin interface. Apps that fail to utilise Polaris, or implement their own competing design systems, create a jarring user experience and will routinely fail the Built for Shopify review process.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: