Shopify

Shopify Theme Development: Architecture and Best Practices

How to structure a maintainable Shopify theme — Online Store 2.0 sections, JS architecture, and asset organization.

Tayyab Roy7 min read
Share:

Most Shopify theme codebases I’ve inherited have the same problem: they started clean and turned into a pile of one-off section files, a 4,000-line theme.js with every interaction wired through jQuery selectors, and a global stylesheet nobody’s confident editing. None of that is inevitable. Online Store 2.0 gives you real architectural primitives — JSON templates, reusable sections, scoped blocks — and if you lean into them deliberately, a Shopify theme can be as organized as any component-based frontend codebase. This is the structure I use when building or refactoring a theme from scratch.

Online Store 2.0: What Actually Changed

Before Online Store 2.0, templates were .liquid files with sections hardcoded into them — if marketing wanted to reorder the homepage, that meant a code change and a deploy. OS 2.0 introduced JSON templates, where a template file just lists which sections to render and in what order:

{
  "sections": {
    "hero": {
      "type": "hero-banner",
      "settings": {
        "heading": "Summer Collection",
        "bg_color": "#f5f5f0"
      }
    },
    "featured": {
      "type": "featured-collection",
      "settings": {
        "collection": "summer-2026"
      }
    },
    "main": {
      "type": "main-product"
    }
  },
  "order": ["hero", "featured", "main"]
}

This single change is why “sections everywhere” matters: every template, including product and collection pages (not just the homepage), is now composed from reorderable, merchant-editable sections. It also enabled app blocks — third-party apps can inject a block into any section that supports them, without the theme developer writing integration code for every app a merchant might install. If you’re building sections intended for general use, add "blocks": [{ "type": "@app" }] to the schema so apps have a slot to render into.

For the Liquid syntax and schema details behind sections themselves, see Shopify Liquid: Complete Guide for Frontend Developers — this article is about how those pieces fit together at the theme level.

Folder Structure

A theme’s top-level layout is fixed by Shopify, but how you organize within it is not:

theme/
├── assets/          # compiled CSS, JS, images, fonts
├── config/
│   ├── settings_schema.json   # global theme settings (theme editor "Theme Settings")
│   └── settings_data.json     # merchant's saved values for those settings
├── layout/
│   └── theme.liquid           # the HTML shell, <head>, global scripts
├── locales/         # translation files
├── sections/        # one file per section type
├── snippets/        # small reusable render() targets
└── templates/
    ├── index.json
    ├── product.json
    ├── collection.json
    └── customers/

The discipline that pays off: sections should not reference each other directly. A section shouldn’t assume a sibling section exists on the page. If two sections need to share data, pass it down from the template/page level via settings, or read from a shared global object (cart, shop) rather than reaching into DOM elements another section owns. This keeps sections genuinely composable — you can drag one into a different template without silently breaking something.

Within assets/, I organize compiled output per section where it makes sense (section-hero.css, section-hero.js) rather than one monolithic bundle. Shopify serves assets from its CDN with aggressive caching, so smaller, section-scoped files mean a change to one section doesn’t invalidate the cache for unrelated ones — and you avoid shipping JS for a section that isn’t even on the current page.

A Sane Approach to Theme JavaScript

The default trap in theme JS is years of accumulated jQuery-style code: a single theme.js that does document.querySelectorAll('.product-card') at the top and wires up handlers globally, with no isolation between features. It works until it doesn’t — a class name collision between two unrelated sections, or a handler that fires twice because a snippet got rendered in two templates.

The fix I’ve had the most success with is treating each interactive section as a native Web Component, scoped to its own tag name, with no framework dependency and no build step required:

<!-- sections/image-comparison.liquid -->
<image-comparison-slider class="image-comparison" data-start="{{ section.settings.start_position }}">
  <img src="{{ section.settings.image_before | image_url: width: 800 }}" alt="Before">
  <img src="{{ section.settings.image_after | image_url: width: 800 }}" alt="After">
  <input type="range" min="0" max="100" value="{{ section.settings.start_position }}">
</image-comparison-slider>

<script src="{{ 'image-comparison.js' | asset_url }}" defer></script>
// assets/image-comparison.js
class ImageComparisonSlider extends HTMLElement {
  connectedCallback() {
    this.slider = this.querySelector('input[type="range"]');
    this.afterImage = this.querySelectorAll('img')[1];
    this.slider.addEventListener('input', this.handleInput.bind(this));
    this.updateClip(this.slider.value);
  }

  handleInput(event) {
    this.updateClip(event.target.value);
  }

  updateClip(value) {
    this.afterImage.style.clipPath = `inset(0 ${100 - value}% 0 0)`;
  }

  disconnectedCallback() {
    this.slider.removeEventListener('input', this.handleInput);
  }
}

customElements.define('image-comparison-slider', ImageComparisonSlider);

This gets you several things for free: the component’s logic is self-contained and only runs if the custom element is actually present on the page, connectedCallback/disconnectedCallback give you clean lifecycle hooks (important on Shopify’s theme editor, where sections get added and removed from the DOM live as merchants edit), and there’s zero framework overhead. No build step, no bundler required, works in every evergreen browser.

For simpler sections that don’t need the full Web Component lifecycle, a small vanilla module using event delegation on a scoped container is enough:

// assets/product-form.js
document.addEventListener('click', (event) => {
  const button = event.target.closest('[data-variant-select]');
  if (!button) return;

  const form = button.closest('product-form');
  if (form) form.updateVariant(button.dataset.variantId);
});

Delegating from document (or a stable ancestor) instead of attaching a listener per element means you don’t have to re-bind handlers every time Shopify’s theme editor re-renders a section, which is a real and common source of “the button stopped working after I edited this in the customizer” bugs.

CSS Architecture

Two approaches coexist in most real themes, and that’s fine as long as the boundary is intentional:

  • A global stylesheet for true cross-cutting concerns: CSS custom properties for the color/type scale, base typography, button and form resets, grid utilities.
  • Scoped section styles for anything specific to one section, loaded only on templates that use it.
{% stylesheet %}
.hero-banner {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2rem;
}

.hero-banner__heading {
  font-size: clamp(2rem, 5vw, 4rem);
}
{% endstylesheet %}

The {% stylesheet %} tag (available inside section files in current Online Store 2.0 themes) keeps a section’s CSS colocated with its markup and schema, and Shopify handles bundling it appropriately. This avoids the classic problem of a 3,000-line global CSS file where nobody’s sure if .product-card h3 is still used anywhere. Prefix section-specific classes with the section name (.hero-banner__heading rather than a generic .heading) to avoid specificity fights between sections that happen to reuse a class name.

Common Mistakes

Treating settings_data.json as a file you hand-edit. It’s the merchant’s live configuration, generated by the theme editor. Editing it directly in a theme you’re also pushing via CLI leads to merge conflicts between what the merchant configured in the editor and what you deployed. Keep schema/defaults in the section files; let settings_data.json be owned by whoever is editing live.

One giant theme.js loaded on every page. Audit what’s actually loading in your layout/theme.liquid — if a script is only needed for the cart drawer, it shouldn’t block rendering on a blog post. This ties directly into page speed; see Shopify Performance Optimization for the deferred-loading patterns that go with this structure.

No naming convention between sections and their assets. sections/hero-banner.liquid pairing with assets/hero-banner.js and assets/section-hero-banner.css sounds trivial, but on a theme with 40+ sections, a consistent 1:1 naming convention is the difference between finding a file in two seconds and grepping for it.

When to Reach for This vs. Headless

This section-based architecture is the right call for most merchant-facing stores, especially ones where non-developers need to rearrange the page regularly through the theme editor. If you’re building something closer to a fully custom frontend experience with no theme-editor requirement — a highly bespoke storefront, deep custom checkout flows, or a shared design system across web and app — a headless approach (Hydrogen, or a custom storefront against the Storefront API) trades editor flexibility for full frontend control. The architectural principles in this article — scoped components, event delegation, avoiding monolithic bundles — carry over to that world too; see Scalable Frontend Architecture for the broader, framework-agnostic version of this thinking.

#Shopify#Architecture#Theme Development

Written by

Tayyab Roy

Senior Frontend / Full-Stack Engineer

Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.