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.
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:
- Which app version is currently released to the shop?
- Which theme is actually being viewed: live, unpublished preview, or development?
- Is the failing surface an app block or an app embed block?
- Are you testing in the theme editor, a preview URL, or the live storefront?
- Does the failing behavior depend on extension assets only, or on a fetch back to your Rails app?
| Symptom | Usually means | First check |
|---|---|---|
| Block not available in picker | Wrong release, unsupported template, or schema restriction | Released app version, enabled_on, template support |
| Embed visible in app setup but not storefront | Embed still disabled in theme, wrong theme, or template restriction | App embeds state, live theme, enabled_on |
| Works in editor, fails live | Editor-specific state, wrong assigned template, or JS init assumption | Live URL, assigned template, network panel, console |
| Block markup renders but UI is dead | Asset loaded but JS crashed or backend fetch failed | Console errors, fetch responses, selector drift |
| Deploy succeeded but nothing changed | You released the extension but not the Rails app, or vice versa | Release 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.
| Type | What must be true | Common failure |
|---|---|---|
| App block | Target theme uses JSON templates and the target section supports and renders @app blocks | Merchant added nothing, target section does not support app blocks, or schema limits visibility |
| App embed block | Merchant enabled the embed in Theme settings > App embeds, or your app activated it via deep link | Embed 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.jsonto inspect app embed state. - Fetch the relevant template JSON files, such as
templates/product.jsonortemplates/index.json, to inspect app block placement. - Use the block
typestring 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
endThat 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
endclass Internal::ThemeExtensionDiagnosticsController < Internal::BaseController
def show
report = ThemeExtensionDiagnostics.new(
shop: current_shop,
app_handle: "my-app-handle"
).call
render json: report
end
endThat 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
- Confirm the released app version and recent extension changes.
- Identify the actual theme under test.
- Determine whether the failing unit is a block or an embed.
- Inspect live theme files, not screenshots.
- Verify schema restrictions like
enabled_onandavailable_if. - Test editor and live storefront separately.
- Inspect network requests to Rails separately from extension asset loading.
- 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
Shopify Dev: About theme app extensions
Shopify Dev: Configure theme app extensions
Shopify Dev: Verify theme support
Shopify Dev: App blocks for themes
Shopify Dev: The theme editor
Shopify Dev: Integrate sections and blocks with the theme editor
Shopify Dev: Deploy app versions
Shopify Dev: shopify app deploy
Shopify Dev: Admin GraphQL theme query
Shopify Dev: OnlineStoreTheme object
Shopify Dev changelog: Theme and theme file management in the Admin GraphQL API
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
Common Theme App Extension failures in Shopify and how to debug them
A failure-pattern guide for Shopify Theme App Extensions covering the issues that appear most often in production and the shortest debugging path for each one.
How to detect whether a merchant enabled your app block or app embed
Practical detection patterns for Shopify app blocks and app embeds, including App Bridge extension status in the embedded app and theme inspection strategies when you need server-side truth.
Theme App Extensions with a Rails-backed Shopify app
How to structure Theme App Extensions when your Shopify app backend is Rails, including configuration ownership, data flow, and the boundary between theme-safe data and app-owned backend logic.