Shopify Custom Liquid Section: Script Tags That Work

Yes, Shopify Custom Liquid section script tags are allowed and they execute on the storefront. The reason most devs think they do not work is a different problem: the theme editor injects the section after DOMContentLoaded has fired, so any code waiting on that event silently does nothing. This guide covers the four script patterns that actually run, the schema-settings handoff for passing data into JS, the section reload events you need to listen for, and the anti-patterns that break stores. Read the Custom Liquid section examples post first if you want the broader context.


Most devs paste a <script> block into Custom Liquid, refresh the editor, and nothing happens. The block looks fine. The console is silent. Here is what is actually going on, and the patterns I use across 100+ client stores to ship Custom Liquid scripts that survive theme edits, page reloads, and the Shopify Section Rendering API.

Can you add script tags inside a Shopify Custom Liquid section?

Yes. Shopify does not strip script tags from the Custom Liquid section type, and inline JavaScript runs on both the live storefront and inside the theme editor preview. The Custom Liquid block exposes a single html setting in its schema, and that setting is rendered raw into the page output. Whatever you paste into the merchant-facing textarea or hardcode into the section file gets dropped into the DOM as-is.

Here is what the section schema looks like under the hood:

{% schema %}
{
  "name": "Custom Liquid",
  "settings": [
    {
      "type": "liquid",
      "id": "custom_liquid",
      "label": "Liquid"
    }
  ],
  "presets": [
    { "name": "Custom Liquid" }
  ]
}
{% endschema %}

The type: "liquid" setting accepts the full Liquid grammar plus any HTML, CSS, or JS the merchant wants. There is no allowlist filter on the output. That is the whole reason this section exists: it is the escape hatch for theme code that does not warrant a custom snippet.

Why do my script tags not execute in Custom Liquid?

The most common cause is a DOMContentLoaded listener that fires before the section is even in the DOM. When a merchant adds a Custom Liquid block in the theme editor and saves, Shopify rerenders just that section through the Section Rendering API and injects it into the existing page. By the time your script reaches the browser, DOMContentLoaded has already fired minutes ago, so the callback never runs.

A second, more subtle cause is duplicate execution. The merchant opens the editor, saves, opens again, drags the section to a new position. Each of those triggers a section reload, which means your inline script tag executes again. If your code attaches event listeners to document.body or sets globals, you now have two copies running at once.

A third cause is the Liquid {% raw %} and comment tags. If your script contains characters like {{ or {%, Liquid will try to parse them as variables and either error out or silently strip them. Wrap any JavaScript that uses template literals or object destructuring in {% raw %} ... {% endraw %}.

{% raw %}
<script>
  const data = { items: [{ price: 100 }] };
  const tpl = `Price: ${data.items[0].price}`;
</script>
{% endraw %}

The 4 working script patterns inside Custom Liquid

There are four patterns I use depending on whether the script needs DOM, runs once, or has to handle theme editor reloads. Each one solves a specific failure mode.

Pattern 1: Inline IIFE with no DOM dependency

Use this when you need to set a CSS variable, write a single tracking call, or do something that does not touch the DOM tree below the section.

<script>
  (function() {
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'custom_liquid_loaded',
      section: 'hero-banner'
    });
  })();
</script>

This runs the moment the parser hits the script tag. No event listener, no race condition, no theme editor surprise.

Pattern 2: Scoped initialization with a ready check

Use this when you need access to elements inside the section itself.

<div class="custom-cta" data-cta-id="hero-1">
  <a href="/collections/all">Shop all</a>
</div>

<script>
  (function() {
    function init() {
      const cta = document.querySelector('[data-cta-id="hero-1"]');
      if (!cta) return;
      cta.addEventListener('click', function() {
        // Track the click
      });
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }
  })();
</script>

The readyState check is the missing piece. It runs init immediately if the DOM is already parsed, and waits if not. This works on the live storefront and inside the theme editor.

Pattern 3: External script with defer

Use this for libraries over a couple of KB or anything you want to keep separate from the section markup.

<script src="{{ 'custom-cta.js' | asset_url }}" defer></script>

The asset_url filter resolves to the theme’s CDN path. The defer attribute waits until the document is parsed before executing, which sidesteps the DOMContentLoaded ordering issue. For deeper Liquid filter coverage, see the Shopify Liquid development guide.

Pattern 4: Section-aware initialization for the theme editor

Use this when you need the script to reinitialize every time the merchant edits the section.

<div class="custom-cta" id="cta-{{ section.id }}">
  <button>Buy now</button>
</div>

<script>
  (function() {
    const sectionId = '{{ section.id }}';

    function init(id) {
      const root = document.getElementById('cta-' + id);
      if (!root) return;
      const btn = root.querySelector('button');
      btn.addEventListener('click', () => console.log('clicked', id));
    }

    init(sectionId);

    document.addEventListener('shopify:section:load', function(event) {
      if (event.detail.sectionId === sectionId) {
        init(sectionId);
      }
    });
  })();
</script>

The section.id Liquid object gives you a unique handle per section instance. Combined with the shopify:section:load event, the script reattaches its listeners every time the merchant saves a change in the editor.

How to handle Shopify section reload events from a Custom Liquid script

Shopify exposes seven section and block events from the theme editor, but only two matter for most Custom Liquid scripts: shopify:section:load and shopify:section:unload. These fire when a merchant adds, edits, removes, or reorders a section. They do not fire on the live storefront, which means your code needs to handle both contexts cleanly.

The full event list is:

  • shopify:section:load - section was added or changed
  • shopify:section:unload - section was removed
  • shopify:section:select - merchant clicked the section in the editor
  • shopify:section:deselect - merchant clicked away
  • shopify:section:reorder - section position changed
  • shopify:block:select - merchant clicked a block
  • shopify:block:deselect - merchant clicked away from a block

Each event carries event.detail.sectionId so you can scope the handler to the specific section instance.

<script>
  (function() {
    const sectionId = '{{ section.id }}';
    let cleanup = null;

    function setup() {
      const root = document.getElementById('cta-' + sectionId);
      if (!root) return;

      const handler = () => console.log('clicked');
      root.addEventListener('click', handler);

      cleanup = () => root.removeEventListener('click', handler);
    }

    function teardown() {
      if (typeof cleanup === 'function') {
        cleanup();
        cleanup = null;
      }
    }

    setup();

    document.addEventListener('shopify:section:load', function(event) {
      if (event.detail.sectionId === sectionId) {
        teardown();
        setup();
      }
    });

    document.addEventListener('shopify:section:unload', function(event) {
      if (event.detail.sectionId === sectionId) {
        teardown();
      }
    });
  })();
</script>

The teardown step is what stops listener leaks. Without it, every section save in the editor stacks another click handler on the same button, and after five edits the merchant’s click fires five tracking events.

How do I pass data from theme settings into a Custom Liquid script?

The reliable pattern is a <script type="application/json"> block holding the data, parsed at runtime by your main script. Direct interpolation of Liquid variables into JavaScript string literals breaks as soon as a merchant types a quote, apostrophe, or newline into a settings field.

Here is the wrong way:

<script>
  const heading = "{{ section.settings.heading }}";
</script>

If the merchant enters Bob's "Best" Sale, the rendered output becomes broken JavaScript and the page errors out.

Here is the correct pattern:

<script type="application/json" id="cta-data-{{ section.id }}">
  {
    "heading": {{ section.settings.heading | json }},
    "ctaUrl": {{ section.settings.cta_url | json }},
    "trackingId": {{ section.settings.tracking_id | json }}
  }
</script>

<script>
  (function() {
    const sectionId = '{{ section.id }}';
    const dataNode = document.getElementById('cta-data-' + sectionId);
    if (!dataNode) return;

    const data = JSON.parse(dataNode.textContent);
    console.log(data.heading);
  })();
</script>

The json filter handles all escaping. Strings get quoted and escaped properly, numbers and booleans pass through, and nil becomes null. This is the same pattern Shopify’s own Dawn theme uses for product data on the PDP.

For prices specifically, render the formatted value through Liquid first so currency formatting matches the rest of the store:

<script type="application/json" id="cta-data-{{ section.id }}">
  {
    "priceFormatted": {{ product.price | money | json }},
    "priceCents": {{ product.price | json }}
  }
</script>

The first value is the merchant-facing string like $29.00. The second is the integer in cents for any logic comparisons.

Anti-patterns: 5 things to never do in a Custom Liquid script tag

These five patterns have caused production incidents on stores I have worked on or audited. Skip them.

1. Never override window.fetch or other global browser APIs

I have seen a Custom Liquid script that wrapped window.fetch to log every API call. The wrapper had a typo in its return path, and the entire site stopped loading product variants. Cart updates failed silently. The store was down for forty minutes before anyone noticed. If you need to observe network activity, use the browser dev tools or a tag manager. Do not patch globals from a section file.

2. Never use document.write

It blocks the parser, fails inside async contexts, and is explicitly forbidden by every modern code review checklist. If a tutorial tells you to use it, the tutorial is from 2009.

3. Never hardcode prices as numbers

Use the money filter for display and the raw integer in cents for logic. Hardcoding $29 as a JavaScript string breaks the moment the merchant changes their currency or runs a sale.

{% raw %}
// Wrong
const price = "$29.00";

// Right
const priceCents = {{ product.price | json }};
const priceDisplay = {{ product.price | money | json }};
{% endraw %}

4. Never modify the DOM inside a MutationObserver callback without disconnecting first

This causes infinite loops. The observer fires on a mutation, your callback mutates the DOM, the observer fires again, and the browser tab freezes. If you need to react to DOM changes, disconnect the observer at the start of your callback and reconnect at the end.

5. Never paste 90 lines of business logic into a section file

I removed exactly that from a client section during a Shopify Markets FOUC fix. It was a price-rewriting IIFE that listened for currency changes, parsed the DOM, and reformatted every price element. Moved to a proper theme asset, the same logic ran cleaner, was easier to debug, and stopped showing up in Lighthouse blocking-time reports. Section files are for section-specific behavior. Anything broader belongs in theme.js or its own asset.

For the broader question of when to use Liquid versus apps, see replacing Shopify apps with Liquid snippets.

Production-grade example: a CTA tracker using Custom Liquid

Here is a complete, production-ready Custom Liquid script that tracks button clicks, reads merchant settings from the theme editor, and handles section reloads correctly.

{% raw %}{% comment %} Schema is defined elsewhere in the section file {% endcomment %}{% endraw %}

<div class="kf-cta" id="kf-cta-{{ section.id }}">
  <h2>{{ section.settings.heading | escape }}</h2>
  <a href="{{ section.settings.cta_url }}" class="kf-cta__button">
    {{ section.settings.cta_label | escape }}
  </a>
  <span class="kf-cta__price">{{ product.price | money }}</span>
</div>

<script type="application/json" id="kf-cta-data-{{ section.id }}">
  {
    "trackingId": {{ section.settings.tracking_id | default: '' | json }},
    "heading": {{ section.settings.heading | json }},
    "priceCents": {{ product.price | default: 0 | json }}
  }
</script>

<script>
  (function() {
    const sectionId = '{{ section.id }}';
    let teardown = null;

    function readData() {
      const node = document.getElementById('kf-cta-data-' + sectionId);
      if (!node) return null;
      try {
        return JSON.parse(node.textContent);
      } catch (err) {
        return null;
      }
    }

    function init() {
      const root = document.getElementById('kf-cta-' + sectionId);
      const data = readData();
      if (!root || !data) return;

      const button = root.querySelector('.kf-cta__button');
      if (!button) return;

      const handler = function(event) {
        if (window.dataLayer) {
          window.dataLayer.push({
            event: 'kf_cta_click',
            tracking_id: data.trackingId,
            section_id: sectionId,
            price_cents: data.priceCents
          });
        }
      };

      button.addEventListener('click', handler);
      teardown = function() {
        button.removeEventListener('click', handler);
      };
    }

    function destroy() {
      if (typeof teardown === 'function') {
        teardown();
        teardown = null;
      }
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else {
      init();
    }

    document.addEventListener('shopify:section:load', function(event) {
      if (event.detail.sectionId === sectionId) {
        destroy();
        init();
      }
    });

    document.addEventListener('shopify:section:unload', function(event) {
      if (event.detail.sectionId === sectionId) {
        destroy();
      }
    });
  })();
</script>

This script handles the four cases that break most Custom Liquid scripts. It runs on first paint regardless of where it lands in the document. It reads merchant settings through the JSON pattern so quotes and apostrophes do not break it. It reattaches listeners when the merchant edits the section in the theme editor. And it cleans up when the section is removed, so listeners do not leak across edits.

For the surrounding theme editor context and how merchants actually configure these sections, see the Shopify theme customization guide.

FAQ

Are script tags allowed in Shopify Custom Liquid sections?

Yes. Shopify allows raw HTML, CSS, and JavaScript inside the Custom Liquid section type, including inline script tags and references to external scripts. The Custom Liquid block uses an html setting under the hood, and Shopify renders that content into the page without sanitizing script tags out. You can paste a working script tag in there and it will execute on the storefront.

Why does my Custom Liquid script not run in the theme editor?

The theme editor reloads the section after the initial DOM is parsed, which means DOMContentLoaded has already fired by the time your script gets injected. Listen for the shopify:section:load event instead, or wrap the code in an IIFE that runs immediately. Inline scripts that do not depend on DOM ready will execute fine on both the live storefront and inside the editor preview.

Can I load an external CDN script from Custom Liquid?

Usually yes, but some merchants enable Content Security Policy headers through apps or custom Liquid that restrict third-party domains. If your script tag is blocked, the browser console will show a CSP violation. Use the async or defer attribute to avoid blocking page render, and self-host critical libraries inside the assets folder where possible to remove the CSP risk entirely.

How do I pass theme settings into a Custom Liquid script?

Render the values as a JSON object inside a script type=‘application/json’ tag, then read it from your main script using JSON.parse. Do not interpolate Liquid directly into JavaScript string literals because quotes and apostrophes inside merchant-entered text will break the syntax. The JSON tag pattern handles escaping correctly through Liquid’s built-in json filter.

Does the section reload event fire on the live storefront?

No. The shopify:section:load and shopify:section:unload events only fire inside the theme editor when a merchant edits or moves the section. On the live storefront, your initialization code runs once on page load and that is it. Production scripts should run their setup in both contexts so the section works the same when the merchant is editing it and when a real customer visits.

Should I put complex JavaScript inside a Custom Liquid section?

Only for one-off behavior tied to that specific section instance. Anything reused across multiple pages belongs in a theme asset file or a snippet referenced from theme.liquid. I have removed roughly 90 lines of inline price-rewriting JavaScript from a single section file on a real client store, and moving it to a proper asset cut both maintenance time and page weight.


Need a Custom Liquid section debugged or built from scratch? Book a free 30-minute strategy call.

Need a Liquid Developer Who Understands CRO?

I'll audit your theme code and show you exactly what's costing you conversions. 12+ years of Shopify Liquid experience across 100+ stores.

Get a Free Code Review

Frequently Asked Questions

Are script tags allowed in Shopify Custom Liquid sections?

Yes. Shopify allows raw HTML, CSS, and JavaScript inside the Custom Liquid section type, including inline script tags and references to external scripts. The Custom Liquid block uses an html setting under the hood, and Shopify renders that content into the page without sanitizing script tags out. You can paste a working script tag in there and it will execute on the storefront.

Why does my Custom Liquid script not run in the theme editor?

The theme editor reloads the section after the initial DOM is parsed, which means DOMContentLoaded has already fired by the time your script gets injected. Listen for the shopify:section:load event instead, or wrap the code in an IIFE that runs immediately. Inline scripts that do not depend on DOM ready will execute fine on both the live storefront and inside the editor preview.

Can I load an external CDN script from Custom Liquid?

Usually yes, but some merchants enable Content Security Policy headers through apps or custom Liquid that restrict third-party domains. If your script tag is blocked, the browser console will show a CSP violation. Use the async or defer attribute to avoid blocking page render, and self-host critical libraries inside the assets folder where possible to remove the CSP risk entirely.

How do I pass theme settings into a Custom Liquid script?

Render the values as a JSON object inside a script type='application/json' tag, then read it from your main script using JSON.parse. Do not interpolate Liquid directly into JavaScript string literals because quotes and apostrophes inside merchant-entered text will break the syntax. The JSON tag pattern handles escaping correctly through Liquid's built-in json filter.

Does the section reload event fire on the live storefront?

No. The shopify:section:load and shopify:section:unload events only fire inside the theme editor when a merchant edits or moves the section. On the live storefront, your initialization code runs once on page load and that is it. Production scripts should run their setup in both contexts so the section works the same when the merchant is editing it and when a real customer visits.

Should I put complex JavaScript inside a Custom Liquid section?

Only for one-off behavior tied to that specific section instance. Anything reused across multiple pages belongs in a theme asset file or a snippet referenced from theme.liquid. I have removed roughly 90 lines of inline price-rewriting JavaScript from a single section file on a real client store, and moving it to a proper asset cut both maintenance time and page weight.

Book Strategy Call