If a Shopify section only works on first page load, it is not done.
That is the fastest way to frame the problem. A lot of section code looks fine in a storefront preview, then breaks the first time a merchant reorders a section, selects a block, or changes a color setting in the theme editor. Shopify’s own docs explain why: sections and blocks can be added, removed, and re-rendered directly onto the existing DOM, and JavaScript that ran on page load will not automatically run again in that editor flow. That changes the bar for what “working” means in production.
This is especially relevant now that more section scaffolding is being done with AI tools. The generated code is often plausible. The problem is that “plausible” is not the same thing as “safe in the theme editor.”
Why this matters
Shopify’s theme editor is not a normal full-page navigation model. In the official guide for integrating sections and blocks with the theme editor, Shopify says editor actions can add, remove, or re-render sections directly in the existing DOM, and that page-load JavaScript will not rerun automatically.
That single detail explains a lot of weird bugs:
- sliders that work on first load and break after a reorder
- event listeners that fire twice after settings changes
- block selection that does not keep the selected content visible
- styles that should respond to merchant settings but do not update live
- valid-looking schema that quietly creates a bad authoring experience
1. Write section JavaScript for re-renders, not just first load
The most important Shopify editor events are not optional trivia.
In the same theme editor guide, Shopify documents shopify:section:load for when a section has been added or re-rendered, and shopify:section:unload for when a section is being deleted or re-rendered and you need to clean up listeners, variables, and other state. That is the lifecycle your section code has to respect.
The wrong mental model is “run this script on DOMContentLoaded.”
The better mental model is “given a section root, can I mount behavior onto it safely, then tear it down safely?”
Here is a compact vanilla JavaScript pattern that scales well. TypeScript is fine if your theme build compiles it down cleanly, but Shopify themes ultimately ship JavaScript to the browser, so plain JavaScript is the safer default example here:
class PromoCarousel {
constructor() {
this.cleanup = null
}
mount(section) {
const track = section.querySelector('[data-carousel-track]')
const next = section.querySelector('[data-carousel-next]')
if (!track || !next) return
const handleNext = () => {
track.scrollBy({ left: track.clientWidth, behavior: 'smooth' })
}
next.addEventListener('click', handleNext)
this.cleanup = () => {
next.removeEventListener('click', handleNext)
}
}
unmount() {
if (this.cleanup) {
this.cleanup()
this.cleanup = null
}
}
}
const instances = new Map()
function mountSection(section) {
const id = section.dataset.sectionId
if (!id) return
const existing = instances.get(id)
if (existing) existing.unmount()
const carousel = new PromoCarousel()
carousel.mount(section)
instances.set(id, carousel)
}
function unmountSection(section) {
const id = section.dataset.sectionId
if (!id) return
const existing = instances.get(id)
if (existing) existing.unmount()
instances.delete(id)
}
document.querySelectorAll('[data-section-id]').forEach(mountSection)
document.addEventListener('shopify:section:load', (event) => {
const section = event.target
if (section instanceof HTMLElement) {
mountSection(section)
}
})
document.addEventListener('shopify:section:unload', (event) => {
const section = event.target
if (section instanceof HTMLElement) {
unmountSection(section)
}
})This pattern is boring on purpose. That is a good thing. You want explicit section boundaries, explicit mount/unmount behavior, and no accidental listener leaks.
2. Use block.shopify_attributes and selected-state behavior deliberately
Another easy miss is block-level authoring behavior.
Shopify’s theme editor docs say sections include the necessary editor attribute by default, but blocks do not. For blocks, you need to add {{ block.shopify_attributes }} manually. If you skip it, block selection in the editor becomes harder or breaks outright.
A safe default looks like this:
<div class="promo-grid" data-section-id="{{ section.id }}">
{% for block in section.blocks %}
<article
class="promo-grid__item"
{{ block.shopify_attributes }}
>
<h3>{{ block.settings.heading }}</h3>
<p>{{ block.settings.copy }}</p>
</article>
{% endfor %}
</div>The production rule is simple: every rendered block wrapper should have one clear {{ block.shopify_attributes }} hook, and any interactive UI should handle block selection states predictably.
For example, if the section is a slider, accordion, or tab UI, your shopify:block:select handling should make the selected block visible and keep it visible while selected. Shopify calls this out explicitly because the editor may scroll to the block, but your component still has to reveal it correctly.
3. Use {% style %} for instance-specific CSS and {% stylesheet %} for static CSS
This is one of the most common Shopify mistakes I see in AI-generated section code.
Shopify’s style tag docs say it generates an HTML style tag with data-shopify. More importantly, Shopify notes that if you reference color settings inside style tags, the CSS rules update in the theme editor without a page refresh.
By contrast, Shopify’s stylesheet tag docs warn that Liquid is not rendered inside {% stylesheet %}. That makes the default split pretty straightforward.
| Use case | Right primitive | Why |
|---|---|---|
| Merchant-controlled values tied to one section instance | {% style %} |
Supports Liquid values and editor-friendly live updates |
| Shared static CSS for the section file | {% stylesheet %} |
Good for regular CSS that does not need Liquid |
| Dynamic values like per-section colors, spacing, or aspect ratios | {% style %} |
Liquid inside {% stylesheet %} can cause syntax or rendering problems |
| Reusable global design system rules | regular asset or static stylesheet strategy | Better than repeating large inline chunks per section |
A good section usually uses both: static layout rules in a stylesheet path, and a small {% style %} block for truly instance-specific values.
{% stylesheet %}
.promo-grid {
display: grid;
gap: 1rem;
}
.promo-grid__item {
border-radius: 0.75rem;
}
{% endstylesheet %}
{% style %}
#shopify-section-{{ section.id }} .promo-grid__item {
background: {{ section.settings.card_background }};
}
{% endstyle %}That split is cleaner than trying to jam setting-driven CSS into {% stylesheet %} and hoping for the best.
4. Keep section schema boring, explicit, and editor-first
The section schema docs list the supported structure clearly: name, tag, class, limit, settings, blocks, max_blocks, presets, default, locales, enabled_on, and disabled_on.
The production lesson is not “memorize every key.” It is “do not invent your own schema language.”
In practice, the mistakes tend to be predictable:
- adding JSON-schema-like keys that Shopify does not use
- skipping useful defaults, so the section is inserted in a broken state
- exposing too many settings that create fragile combinations
- forgetting that schema quality is part of the merchant experience, not just developer correctness
A strong schema default is restrictive enough to be safe, but flexible enough to be useful.
{% schema %}
{
"name": "Promo grid",
"tag": "section",
"class": "section section--promo-grid",
"max_blocks": 6,
"settings": [
{
"type": "color",
"id": "card_background",
"label": "Card background",
"default": "#111827"
}
],
"blocks": [
{
"type": "promo",
"name": "Promo card",
"settings": [
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "Free shipping over $75"
},
{
"type": "textarea",
"id": "copy",
"label": "Copy",
"default": "Add concise supporting copy here."
}
]
}
],
"presets": [
{
"name": "Promo grid",
"blocks": [
{ "type": "promo" },
{ "type": "promo" }
]
}
]
}
{% endschema %}The big idea is that good schema reduces both runtime bugs and editor confusion.
5. Validate with Theme Check, then test real editor interactions
Automation helps here, but only if you use it for the right layer.
Shopify says Theme Check is a linter for the Liquid and JSON inside themes and theme app extensions. It detects errors and enforces Liquid best practices. That makes it a solid default gate for catching syntax errors, deprecated patterns, and invalid theme code before review.
What it does not do is replace editor interaction QA.
A section can pass Theme Check and still be annoying in the editor. It might initialize twice. It might fail to reveal the selected block. It might leak listeners after a re-render. Those are runtime behavior problems, not just lint problems.
So the checklist I would actually use looks like this:
A production checklist for custom Shopify sections
- Does the section work on first page load?
- Does it still work after
shopify:section:loadfires? - Does it clean up correctly on
shopify:section:unload? - If it renders blocks, does each block wrapper include
{{ block.shopify_attributes }}exactly once? - If it uses merchant-controlled visual values, are those values in
{% style %}instead of{% stylesheet %}? - Is the schema minimal, valid, and seeded with sensible defaults?
- Does Theme Check pass?
- Did you test reorder, add, remove, and block-select flows in the theme editor?
That is not a huge process. It is just a more accurate definition of done.
My default decision framework
If you want the short version, use this table:
| Problem | Default answer |
|---|---|
| Interactive section state | Mount per section root and support load/unload |
| Block editing UX | Add {{ block.shopify_attributes }} on the rendered block wrapper |
| Setting-driven CSS | Use {% style %} |
| Static section CSS | Use {% stylesheet %} or a shared asset strategy |
| Schema design | Keep it constrained, valid, and preset-backed |
| Validation | Run Theme Check, then test inside the editor |
Tradeoffs and caveats
Not every section needs this much machinery.
If a section is truly static and has no JavaScript, no block interactions, and minimal setting-driven styling, you do not need to force a lifecycle framework into it. The right amount of structure depends on the component.
But the opposite mistake is more common right now: treating all sections like generic HTML partials. That is where teams lose time. Shopify sections are authorable UI modules inside a live editor. That is a stricter environment than a plain server-rendered chunk of markup.
Final takeaway
The most useful Shopify section mindset is simple: optimize for editor behavior, not just first render.
If you do that, the right patterns fall out naturally. Handle section lifecycle events. Add block attributes correctly. Put dynamic CSS in {% style %}. Keep schema restrained. Validate automatically, then test the real editor flows.
If you are using AI tools to scaffold Shopify work, this is also where your review bar should get stricter. The code does not need to look right. It needs to survive the theme editor.
For a broader look at where AI tools help and fail in Shopify work, see Claude Code vs Cursor vs Codex for Shopify Development.