Guides

Developer guide

Shopify Theme App Extension debugging guide

A debugging workflow for Shopify Theme App Extensions that covers extension deployment, theme activation, storefront rendering, and the specific places app blocks and app embeds usually fail.

Published by Addora
Updated March 12, 2026
16 min read
Editorial note: This guide assumes you already know how to build a theme app extension. The job here is to debug the wonderfully annoying cases where the block exists, the app insists everything is fine, and the storefront acts like your code went on holiday.

What you are actually debugging

Theme App Extension debugging is not ordinary theme debugging. You are debugging a chain: extension code, extension version, Shopify release state, merchant activation, theme support, stored theme configuration, storefront rendering, and only then your runtime JavaScript. If you jump straight to “my Liquid is broken”, you are often debugging step seven while step two is on fire.

Shopify positions theme app extensions as versioned, theme-safe integrations with asset hosting on the Shopify CDN. That architecture is excellent for merchants, but it also means stale releases, wrong theme targets, and config drift can all look like “the block does not render”. In other words, the bug can be real even when your code is innocent. Which is nice for your code and terrible for your blood pressure.

“Releasing an app version replaces the current active version.”

There is one more gotcha that bites Rails teams constantly: shopify app deploy creates and releases an app version for extensions and app configuration, but it does not deploy your web app. So the extension can be on version N while your Rails server is still on version N minus one, or the reverse. If your extension script fetches JSON from Rails, that mismatch can create a fake mystery where nothing is technically broken, but everything is incompatible.

The ruthless rule

Before you inspect a single JavaScript error, confirm three things: the released app version, the exact theme being tested, and whether you are debugging an app block or an app embed. Those three checks eliminate a ridiculous amount of fake complexity.

Verify the version, theme, and surface first

A good debugging workflow starts with identity, not code. Ask these questions in this order:

  1. Which app version is currently released to the shop?
  2. Which theme is actually being viewed: live, unpublished preview, or development?
  3. Is the failing surface an app block or an app embed block?
  4. Are you testing in the theme editor, a preview URL, or the live storefront?
  5. Does the failing behavior depend on extension assets only, or on a fetch back to your Rails app?
SymptomUsually meansFirst check
Block not available in pickerWrong release, unsupported template, or schema restrictionReleased app version, enabled_on, template support
Embed visible in app setup but not storefrontEmbed still disabled in theme, wrong theme, or template restrictionApp embeds state, live theme, enabled_on
Works in editor, fails liveEditor-specific state, wrong assigned template, or JS init assumptionLive URL, assigned template, network panel, console
Block markup renders but UI is deadAsset loaded but JS crashed or backend fetch failedConsole errors, fetch responses, selector drift
Deploy succeeded but nothing changedYou released the extension but not the Rails app, or vice versaRelease history and backend deploy state

Shopify’s deployment model makes this distinction explicit. App versions snapshot your app configuration and extensions together, and app deploy releases that version to users. Your Rails app is still your responsibility to deploy separately. So if your extension reads from a Rails endpoint, the extension release pipeline and the web deploy pipeline must be treated as two related but separate systems.

“This command doesn’t deploy your web app.”

For merchant onboarding, deep links are worth using because they cut out an entire class of setup mistakes. Shopify supports deep links for adding app blocks and activating app embeds, and the current docs explicitly say to use api_key in those URLs. uuid is deprecated. So if your install flow still depends on older link shapes, you may be debugging your own museum exhibit.

Check activation and theme support before code

App blocks and app embeds fail differently, so debug them differently.

TypeWhat must be trueCommon failure
App blockTarget theme uses JSON templates and the target section supports and renders @app blocksMerchant added nothing, target section does not support app blocks, or schema limits visibility
App embed blockMerchant enabled the embed in Theme settings > App embeds, or your app activated it via deep linkEmbed remains disabled after install, or is restricted away from the tested template

Shopify’s requirements here are crisp. App blocks need JSON templates plus sections that support and render blocks of type @app. App embed blocks, by contrast, work on both vintage and Online Store 2.0 themes because they do not depend on JSON templates. That difference matters a lot during triage. A missing app block can be a theme support problem. A missing embed is usually an activation, targeting, or runtime problem.

“By default, app embed blocks are deactivated after an app is installed.”

Also inspect your own schema before you accuse Shopify of crimes. Three attributes are frequent culprits: enabled_on, disabled_on, and available_if. The last one is especially sneaky, because Shopify lets you hide a block based on an app-data metafield. That is brilliant for plan gating and absolutely fantastic for causing “it works on my store” arguments.

{% schema %}
{
  "name": "Order banner",
  "target": "section",
  "enabled_on": {
    "templates": ["product"]
  },
  "available_if": "{{ app.metafields.flags.order_banner_enabled }}",
  "settings": []
}
{% endschema %}

The schema above will never appear on a collection template, and it will disappear everywhere if the referenced app-data metafield resolves false. That is not a rendering bug. That is your schema doing exactly what you told it to do while you angrily refresh Chrome.

For activation flows, give merchants one-click routes instead of instructions they can accidentally interpret creatively:

export function appEmbedActivationUrl(shopDomain: string, apiKey: string, handle: string) {
  return `https://${shopDomain}/admin/themes/current/editor?context=apps&activateAppId=${apiKey}/${handle}`;
}
 
export function appBlockAddUrl(
  shopDomain: string,
  apiKey: string,
  handle: string,
  template = "product",
) {
  return `https://${shopDomain}/admin/themes/current/editor?template=${template}&addAppBlockId=${apiKey}/${handle}&target=newAppsSection`;
}

Deep links are not magic, but they convert a vague merchant task into a deterministic setup step, which is exactly what you want in any system that involves themes, previews, humans, and optimism.

Inspect theme state from the Admin API

This is the section that saves support teams from writing the same Slack reply forever. Stop asking merchants to “double-check” whether they enabled something when you can inspect the theme yourself.

Shopify’s theme app extension docs still mention using the Asset REST Admin API to detect whether merchants added app blocks or enabled app embeds. That advice is still valid. But since Admin GraphQL gained theme and theme-file management in version 2024-10, Rails apps can also query themes, filter for the live theme, and fetch configuration files directly through GraphQL. In practice, that gives many Rails codebases a cleaner single-API path.

“As of API version 2024-10, you can use the admin API to manage Online Store Themes.”

The practical debugging move is simple:

  • Query the live theme by filtering for role MAIN.
  • Fetch config/settings_data.json to inspect app embed state.
  • Fetch the relevant template JSON files, such as templates/product.json or templates/index.json, to inspect app block placement.
  • Use the block type string to identify your own extension entries.
session = ShopifyAPI::Auth::Session.new(
  shop: shop.shopify_domain,
  access_token: shop.access_token
)
 
client = ShopifyAPI::Clients::Graphql::Admin.new(session: session)
 
query = <<~GRAPHQL
  query ThemeExtensionDiagnostics($filenames: [String!]!) {
    themes(roles: [MAIN], first: 1) {
      nodes {
        id
        name
        role
        files(filenames: $filenames) {
          nodes {
            filename
            body {
              ... on OnlineStoreThemeFileBodyText {
                content
              }
              ... on OnlineStoreThemeFileBodyUrl {
                url
              }
            }
          }
          userErrors {
            code
            filename
          }
        }
      }
    }
  }
GRAPHQL
 
variables = {
  filenames: [
    "config/settings_data.json",
    "templates/product.json",
    "templates/index.json",
    "templates/collection.json"
  ]
}
 
response = client.query(query: query, variables: variables)
data = response.body.deep_symbolize_keys
theme = data.dig(:data, :themes, :nodes, 0)

Once you have the files, parse defensively. App embed blocks appear in settings_data.json. Shopify documents that an embed remains in that file after first enablement, and when disabled later it stays there with disabled: true. That is excellent for diagnostics because it lets you distinguish “never enabled” from “enabled once, now off”.

class ThemeExtensionState
  APP_BLOCK_PATTERN = %r{shopify://apps/.+/blocks/.+}.freeze
  APP_EMBED_PATTERN = %r{shopify://apps/.+/blocks/app-embed/.+}.freeze
 
  def initialize(files:, app_handle:)
    @files = files.index_by { |f| f[:filename] }
    @app_handle = app_handle
  end
 
  def app_embed_state
    json = parse_json("config/settings_data.json")
    blocks = json.dig("current", "blocks") || {}
 
    match = blocks.values.find do |block|
      type = block["type"].to_s
      type.include?("/apps/#{@app_handle}/") && type.match?(APP_EMBED_PATTERN)
    end
 
    return { enabled: false, reason: "not_present" } unless match
 
    {
      enabled: match["disabled"] != true,
      reason: match["disabled"] == true ? "present_but_disabled" : "present_and_enabled",
      type: match["type"]
    }
  end
 
  def template_has_app_block?(filename)
    json = parse_json(filename)
    sections = json["sections"] || {}
 
    sections.values.any? do |section|
      blocks = section["blocks"] || {}
      blocks.values.any? do |block|
        block["type"].to_s.include?("/apps/#{@app_handle}/") && block["type"].to_s.match?(APP_BLOCK_PATTERN)
      end
    end
  end
 
  private
 
  def parse_json(filename)
    content = @files.dig(filename, :body, :content) || "{}"
    JSON.parse(content)
  rescue JSON::ParserError
    {}
  end
end

That service object will not answer every question, but it will answer the most valuable early question: “is this thing actually configured on the live theme?” That one answer eliminates a shocking amount of fake debugging.

Separate theme editor bugs from live storefront bugs

The theme editor is not just “the storefront with a sidebar”. It has its own state, its own preview behaviors, and a very important JavaScript lifecycle quirk: when sections and blocks are changed in the editor, Shopify dynamically adds, removes, or re-renders HTML in the existing DOM without reloading the entire page. That means page-load JavaScript does not automatically re-run.

So if your block works on a fresh page load but dies after a section re-render, that is not random. Shopify documents the exact lifecycle events for this. You should listen for section and block events and re-initialize or tear down behavior explicitly. This is where many “Shopify randomly broke our block” bugs are actually “we only initialized once on DOMContentLoaded”.

<div class="my-app-block" {{ block.shopify_attributes }}>
  <button data-my-app-toggle>Open panel</button>
  <div hidden data-my-app-panel>Panel content</div>
</div>

Include {{ block.shopify_attributes }} on the block root so the editor can correctly associate selection events with your block. Otherwise block selection and preview behavior can get weird in ways that feel supernatural and are, disappointingly, not supernatural at all.

class MyAppBlockController {
  constructor(root) {
    this.root = root;
    this.button = root.querySelector("[data-my-app-toggle]");
    this.panel = root.querySelector("[data-my-app-panel]");
    this.onClick = this.onClick.bind(this);
  }
 
  mount() {
    if (!this.button || !this.panel) return;
    this.button.addEventListener("click", this.onClick);
  }
 
  destroy() {
    if (this.button) this.button.removeEventListener("click", this.onClick);
  }
 
  onClick() {
    this.panel.hidden = !this.panel.hidden;
  }
}
 
const controllers = new WeakMap();
 
function mountAll(scope = document) {
  scope.querySelectorAll(".my-app-block").forEach((root) => {
    if (controllers.has(root)) return;
    const controller = new MyAppBlockController(root);
    controller.mount();
    controllers.set(root, controller);
  });
}
 
function destroyWithin(scope) {
  if (!scope) return;
  if (scope.matches?.(".my-app-block") && controllers.has(scope)) {
    controllers.get(scope).destroy();
    controllers.delete(scope);
  }
 
  scope.querySelectorAll?.(".my-app-block").forEach((root) => {
    if (!controllers.has(root)) return;
    controllers.get(root).destroy();
    controllers.delete(root);
  });
}
 
document.addEventListener("DOMContentLoaded", () => mountAll());
document.addEventListener("shopify:section:load", (event) => mountAll(event.target));
document.addEventListener("shopify:section:unload", (event) => destroyWithin(event.target));
document.addEventListener("shopify:block:select", (event) => {
  event.target.querySelector("[data-my-app-panel]")?.removeAttribute("hidden");
});

Shopify also gives you editor detection helpers such as request.design_mode in Liquid and Shopify.designMode in JavaScript, plus a separate visual preview mode. Use them sparingly. Shopify’s own guidance is that the editor preview should usually match what customers see live. That means design-mode checks are for editor compatibility, not for papering over production bugs with editor-only branches.

Debug asset loading and JavaScript lifecycle issues

Theme extension assets and your Rails backend are two separate failure domains. Shopify hosts extension assets on its CDN. Your Rails app hosts your private APIs, business logic, and whatever very serious JSON your frontend insists on requesting five times in a row. If the block renders but interactivity fails, do not lump those together.

Shopify’s schema rules matter here. App blocks use target: "section". App embed blocks render before closing </head> or </body> tags, depending on whether you target head, compliance_head, or body. If your script expects DOM elements to exist, a head-targeted embed can absolutely make you feel like you forgot how the browser works.

Shopify also notes that if multiple blocks or embeds reference the same JavaScript or stylesheet asset, the file is only included once. That is usually good. But it means you should not rely on “another instance of the block was added” as your initialization mechanism. Initialization belongs in your runtime code, not in your hopes.

What to inspect when the markup exists but behavior is dead

  • Confirm the extension asset actually loaded and returned 200.
  • Check for JavaScript errors before and after theme editor interactions.
  • Verify your selectors still match the rendered markup after merchant customization.
  • Check any fetch to Rails separately from asset loading.
  • Test the exact template types allowed by enabled_on.
  • Confirm you are not expecting resource-scoped Liquid data in an app embed, which only has global Liquid scope.

That last point matters a lot. App embeds are broad and convenient, but they cannot use dynamic sources the way app blocks can. If your extension depends on product-specific context and you built it as an embed because “it was easier”, congratulations, you have invented a future debugging session.

A good architecture smell test

If the frontend needs authenticated merchant context, admin-only secrets, or app-owned operational logic, that usually belongs in the embedded app. Theme app extensions should stay thin, public, and storefront-safe.

Rails debugging patterns that save real time

The best Rails debugging pattern is to stop hiding this logic inside support folklore and make it queryable. Give your app an internal diagnostics service and, if appropriate, a small support-facing endpoint that summarizes theme extension state for a shop.

class ThemeExtensionDiagnostics
  def initialize(shop:, app_handle:)
    @shop = shop
    @app_handle = app_handle
  end
 
  def call
    files_payload = fetch_theme_files
    theme = files_payload[:theme]
    files = files_payload[:files]
    state = ThemeExtensionState.new(files: files, app_handle: @app_handle)
 
    {
      theme: {
        id: theme[:id],
        name: theme[:name],
        role: theme[:role]
      },
      app_embed: state.app_embed_state,
      app_blocks: {
        product_template: state.template_has_app_block?("templates/product.json"),
        index_template: state.template_has_app_block?("templates/index.json"),
        collection_template: state.template_has_app_block?("templates/collection.json")
      }
    }
  end
 
  private
 
  def fetch_theme_files
    # Use the GraphQL query from the previous section.
    # Return a normalized hash with :theme and :files.
    raise NotImplementedError
  end
end
class Internal::ThemeExtensionDiagnosticsController < Internal::BaseController
  def show
    report = ThemeExtensionDiagnostics.new(
      shop: current_shop,
      app_handle: "my-app-handle"
    ).call
 
    render json: report
  end
end

That endpoint turns “please send screenshots of your theme editor” into structured evidence. It also helps onboarding, because the same report can power setup UI like:

  • Embed enabled: yes or no
  • Live theme: detected theme name
  • Product template has block: yes or no
  • Recommended next step: activate embed, add block, or inspect runtime fetch failure

This is where debugging becomes product design. If merchants repeatedly miss activation, the problem is not that merchants are foolish. The problem is that your product currently requires a treasure hunt.

A blunt runbook that works

  1. Confirm the released app version and recent extension changes.
  2. Identify the actual theme under test.
  3. Determine whether the failing unit is a block or an embed.
  4. Inspect live theme files, not screenshots.
  5. Verify schema restrictions like enabled_on and available_if.
  6. Test editor and live storefront separately.
  7. Inspect network requests to Rails separately from extension asset loading.
  8. Only then debug Liquid or JavaScript details.

Follow that order and you will fix real bugs faster, write less defensive nonsense, and spend fewer afternoons accusing innocent Liquid of felonies it did not commit.

Best internal links

Sources and further reading

FAQ

Why does my app embed show in the theme editor but not on the live storefront?

Most often the wrong theme is being inspected, the embed is disabled in the live theme, the block is limited by enabled_on or available_if, or the JavaScript only works in the editor preview path.

Can a Rails app detect whether a merchant enabled an app embed or added an app block?

Yes. A practical approach is to inspect the current theme's configuration files through the Admin API and parse settings_data.json plus the relevant template JSON files.

Why did shopify app deploy not fix my storefront bug?

Because app deploy releases a versioned snapshot of your app configuration and extensions, but it does not deploy your Rails web app. Extension assets and your backend can drift if you treat them as one deployment unit.

When should I debug an app block differently from an app embed?

Immediately. App blocks depend on JSON templates and @app-compatible sections. App embeds are theme-wide toggles that are disabled by default after install and are configured in App embeds.

Related resources

Keep exploring the playbook