A theme app extension is how a Shopify app puts code into a merchant’s storefront without touching their theme files. You ship Liquid blocks, CSS and JavaScript in your app’s extensions/ directory, they deploy with the app version, and merchants switch them on in the theme editor. Two block types exist, and the choice decides which themes you can support.
App blocks render inline inside a section. App embed blocks render before the closing </head> or </body> tag. Everything else follows from that: which themes can host them, and how a merchant turns them on.
App block or app embed block?
Shopify’s own split is by what the code does. App blocks are for “Your app injects inline content on a page”. App embed blocks are for an app that “loads JavaScript on storefront pages, has no UI component, or adds a floating or overlaid element”.
The target value in the schema separates them. App blocks use target: section. App embeds use head, body or compliance_head, the last of which is rendered first and exists for cookie consent.
Theme coverage is where the choice bites. App blocks only work in themes that contain JSON templates, and the docs are blunt that they “aren’t supported in statically rendered sections”. App embed blocks are supported in vintage and Online Store 2.0 themes alike, because they do not rely on sections or JSON templates at all. So an embed gets you one onboarding flow for every merchant, while an app block gets you placement control and dynamic sources through autofill, plus a second onboarding path for vintage themes.
A complete app block, in blocks/delivery_estimate.liquid, with stylesheet and javascript pointing at files in assets/:
<div
class="app-delivery-estimate"
data-variant-id="{{ product.selected_or_first_available_variant.id }}"
data-cutoff-hour="{{ block.settings.cutoff_hour }}"
>
<p class="app-delivery-estimate__fallback">{{ block.settings.fallback_text }}</p>
</div>
{% schema %}
{
"name": "Delivery estimate",
"target": "section",
"stylesheet": "delivery-estimate.css",
"javascript": "delivery-estimate.js",
"enabled_on": {
"templates": ["product"]
},
"settings": [
{
"type": "number",
"id": "cutoff_hour",
"label": "Same-day cutoff hour",
"default": 14
},
{
"type": "text",
"id": "fallback_text",
"label": "Fallback text",
"default": "Dispatched within two working days"
}
]
}
{% endschema %}
You do not write the wrapper element. Shopify wraps the output in a div unless you set tag, and it always carries the shopify-block class alongside anything you add through class.
An app embed differs only in the schema. Swap target to body and drop enabled_on if it should run everywhere. To gate it on a plan flag, use available_if, which takes a reference to a boolean app-owned metafield:
<div
class="app-cart-nudge"
data-threshold="{{ block.settings.threshold }}"
data-currency="{{ cart.currency.iso_code }}"
hidden
></div>
{% schema %}
{
"name": "Cart nudges",
"target": "body",
"stylesheet": "nudges.css",
"javascript": "nudges.js",
"available_if": "{{ app.metafields.plan.nudges_enabled }}",
"settings": [
{
"type": "number",
"id": "threshold",
"label": "Free shipping threshold",
"default": 50
}
]
}
{% endschema %}
enabled_on and disabled_on both take templates and groups arrays, and you can use only one of the two.
What do the limits actually stop you doing?
Four folders are allowed: assets, blocks, snippets and locales. The configuration reference sets hard ceilings that fail deployment, and softer ones that do not.
Enforced: 10 MB for all files in the extension, 30 blocks, 100 locale files, 15 KB per locale file, and 100 KB of Liquid across every file. The block count went up from 25 to 30 on 3 February 2026. Suggested rather than enforced: 100 KB of compressed CSS and 10 KB of compressed JavaScript.
That 100 KB Liquid budget is the one people hit. Thirty blocks sharing it means the average block gets around 3 KB, so shared markup belongs in snippets/, and strings belong in locale files instead of in Liquid conditionals.
Some Liquid is missing entirely. Theme app extensions cannot reach content_for_header, content_for_index or content_for_layout, and an app block can read only the id property of its parent section. App embeds have it narrower still: they only see the global Liquid scope for the page, which is why they cannot point at dynamic sources.
Shopify’s app performance guidance asks for more than the suggested asset limits. Under “Minimize your bundle size” it says “the app entry point should amount to less than 10KB of JavaScript and less than 50KB of CSS on a page”, and to load on interaction. Use defer when execution order matters and async when it does not.
Why are script tags no longer an option?
Because they stop working. The deprecation changelog, posted 24 August 2026, sets two dates: “Starting October 1, 2026, the scriptTagCreate and scriptTagUpdate mutations will return an user error”, and “On March 1, 2027, Shopify will stop injecting script tags into storefronts”.
The erosion started earlier. Script tags stopped running on the Order status page on 28 August 2025 for Plus stores and 26 August 2026 for everyone else, per the ScriptTag legacy notes. Deletion still works throughout.
If the script only collects analytics or tracks conversions, the replacement is a web pixel, not an app embed. A pixel needs no action from the merchant.
There is also a review gate. App Store requirement 5.1.1 reads: “Use theme app extensions. If your app modifies the merchant’s theme, you need to use theme app extensions. You or merchants should not make any code changes to the theme.” The Asset API is still there for vintage themes, and Shopify’s own guidance marks it as not recommended.
How do I migrate without double-loading?
Order matters, and the migration guide is explicit about the failure: “Running both at once causes duplicate scripts, which can double-count analytics events or render your app’s UI twice.”
So the sequence is: check whether the store’s published theme supports app blocks, and keep the legacy onboarding flow for the ones that do not. For the ones that do, stop POSTing to the Asset and ScriptTag resources, ship the extension, confirm the merchant has activated it, and only then call scriptTagDelete. Against the Admin API, that mutation lives in 2026-07, the current latest stable version, supported until 16 July 2027.
Confirming activation is the step most apps skip. App Bridge’s shopify.app.extensions() resolves to extension info you can filter. Each theme app extension in that array carries activations with a handle, name, target and a status of active, available or unavailable:
const extensions = await shopify.app.extensions();
const themeExtensions = extensions.filter(
(ext) => ext.type === 'theme_app_extension'
);
Activation data comes from the published theme only, so an unpublished copy will not show up. The backend alternative is reading config/settings_data.json from the published theme and looking for your block type, which needs the read_themes scope.
How do merchants actually switch it on?
With a deep link. Both block types support one. {api_key} is your app’s client_id, and {handle} is the block’s Liquid filename without the extension.
# Add an app block
https://<myshopifyDomain>/admin/themes/current/editor?template={template}&addAppBlockId={api_key}/{handle}&target=newAppsSection
# Activate an app embed block
https://<myshopifyDomain>/admin/themes/current/editor?context=apps&template={template}&activateAppId={api_key}/{handle}
The target parameter accepts newAppsSection, sectionGroup:header, sectionGroup:footer, sectionGroup:aside, mainSection or sectionId:{sectionId}. Both block types arrive deactivated, so without a deep link the merchant has to open the theme editor and find your block unaided.
On the theme side, a section opts in by declaring "blocks": [{"type": "@app"}] and rendering with {% content_for 'blocks' %}. That type does not accept the limit parameter, and including it errors. When a merchant adds an app block straight to a template instead of into a section, Shopify wraps it in an apps.liquid section. If you build themes as well as apps, the same schema mechanics show up in our write-up of theme blocks against section blocks.
Start with an app embed block unless the app genuinely needs inline placement next to product content. That gives you one code path across vintage and Online Store 2.0 themes. Add an app block when position on the page is part of the product, and accept that you are then supporting two onboarding flows until the merchant’s theme has JSON templates. If the code only measures things, skip both and ship a web pixel. For the checkout surface the rules differ again, which we covered in what runs where in checkout UI extensions.
We build and maintain Shopify apps and themes for UK merchants, including the unglamorous work of moving an app off script tags before March 2027 without breaking the storefronts it already runs on. If that migration is sitting on your roadmap, our Shopify development work is where it lands.