Theme app extensions are how a Shopify app puts UI into a merchant's storefront without touching theme code. Your app ships Liquid blocks and assets in an extensions/ folder; merchants add app blocks to sections or toggle app embeds in the theme editor; Shopify renders everything from its CDN and removes it cleanly on uninstall.
The problem they solve: apps used to vandalize themes
Before this framework, storefront apps had two bad options. Option one: the ScriptTag API, which bolts your JavaScript onto every page of the storefront. Option two: the old REST Asset API, which let apps write directly into theme files, inserting snippets into theme.liquid and hoping nothing else broke.
Both patterns aged terribly. ScriptTag JS loads render-blocking code on pages that never use it, and it is a repeat offender in the slow-storefront autopsies we covered in why Shopify page speed drops and how to fix it. Asset API edits were worse: when the merchant uninstalled the app, the injected code stayed behind. Ask any theme developer how much of their cleanup work is archaeology on dead app snippets.
Shopify's answer, shipped alongside Online Store 2.0, was to give apps a first-class, sandboxed way in. ScriptTag and the REST Asset API are now legacy (REST as a whole is legacy for app development, per the Admin API's GraphQL-first direction), app review pushes back on new ScriptTag usage, and Built for Shopify status expects theme app extensions for storefront functionality. This is the framework you build on in 2026.
App blocks vs app embeds
A theme app extension contains two kinds of components, and most real apps ship both.
| App block | App embed | |
|---|---|---|
| Schema target | section | head or body |
| Placement | Inside an OS 2.0 section, positioned by the merchant | Appended to the document, no layout position |
| Enabled via | Add block in the theme editor | App embeds toggle in the theme editor |
| Works on vintage themes | No | Yes |
| Typical uses | Reviews widget, size chart, upsell row, badges | Chat bubble, analytics snippet, floating banner, popup |
| Merchant settings | Per placement | Global per theme |
The rule of thumb: if the element belongs somewhere specific in the page flow, it is a block. If it floats above the page or has no visual presence at all, it is an embed.
Folder structure
Scaffold inside an existing app:
shopify app generate extension --template theme_app_extension
You get one extension per app, structured like this:
extensions/my-storefront-widget/
├── shopify.extension.toml
├── blocks/ # each .liquid file is one app block or embed
│ ├── star-rating.liquid
│ └── chat-embed.liquid
├── snippets/ # shared Liquid, reusable across blocks
│ └── stars.liquid
├── assets/ # CSS, JS, images, served from Shopify's CDN
│ ├── rating.css
│ └── rating.js
└── locales/ # translations for block labels and settings
└── en.default.json
Every file in blocks/ becomes something the merchant can add or enable. There are caps to know about: one theme app extension per app, and a limit on blocks per extension (25 at the time of writing, per the configuration docs). Snippets exist so those blocks stay thin.
Anatomy of an app block
Here is a complete review-badge block, blocks/star-rating.liquid:
{% assign rating = block.settings.rating %}
{% assign reviews = block.settings.review_count %}
<div class="axr-badge" style="--axr-accent: {{ block.settings.accent }}">
{% render 'stars', rating: rating %}
<span class="axr-badge__text">
Rated {{ rating }} from {{ reviews }} reviews
</span>
</div>
{% schema %}
{
"name": "Star rating badge",
"target": "section",
"stylesheet": "rating.css",
"javascript": "rating.js",
"settings": [
{
"type": "range",
"id": "rating",
"label": "Rating",
"min": 1,
"max": 5,
"step": 0.1,
"default": 4.8
},
{
"type": "number",
"id": "review_count",
"label": "Review count",
"default": 2000
},
{
"type": "color",
"id": "accent",
"label": "Star color",
"default": "#f5a623"
}
]
}
{% endschema %}
The {% schema %} tag will look familiar if you have built sections; the concepts carry straight over from custom Shopify sections. Points worth calling out:
target: "section"marks this as an app block. Merchants add it inside any OS 2.0 section that accepts app blocks (@appin the section's own schema).stylesheetandjavascriptreference files inassets/. Shopify injects them, deduplicated, only on pages where the block is actually used. This is the performance win over ScriptTag: no block on the page, no bytes shipped.- Settings use theme setting types (
range,color,select,textand friends), the same vocabulary as section schemas, not metafield types. They render as sidebar controls in the theme editor and arrive in Liquid asblock.settings.*.
If your Liquid is rusty, the Liquid basics guide covers the templating side, and for real product data you would typically read from app-owned metafields or metaobjects rather than hardcoded settings.
Anatomy of an app embed
An embed swaps the target and loses the layout assumptions. blocks/chat-embed.liquid:
<div id="axr-chat" data-position="{{ block.settings.position }}"></div>
{% schema %}
{
"name": "Support chat bubble",
"target": "body",
"javascript": "chat.js",
"settings": [
{
"type": "select",
"id": "position",
"label": "Bubble position",
"options": [
{"value": "left", "label": "Bottom left"},
{"value": "right", "label": "Bottom right"}
],
"default": "right"
}
]
}
{% endschema %}
target: "body" appends the markup before </body> on every page of the storefront; "head" exists for meta tags and early scripts. Embeds are off by default on every theme. The merchant enables them from the App embeds panel in the theme editor, which is also your onboarding chokepoint: a large share of "your app doesn't work" tickets are merchants who installed the app but never flipped the toggle. Deep-link them straight there from your onboarding flow using the theme editor's context=apps deep link with your embed's handle pre-activated, and that ticket category mostly disappears.
The performance contract
Apps are the number one reason storefront speed scores collapse, and merchants increasingly know it. Theme app extensions do not make your code fast, but the framework removes the structural causes of slowness:
- Conditional loading. Block assets ship only on pages where the block is present. ScriptTag shipped everywhere, forever.
- Shopify CDN delivery. Everything in
assets/is served from the same CDN as the theme, with proper caching, instead of your app server or a third-party host. - Deduplication. A stylesheet referenced by five block instances loads once.
- Clean uninstall. No orphaned code degrading themes years later.
The rest is on you. Keep block JS dependency-free (no bundled jQuery in 2026, please), defer anything non-critical, and measure a theme with your extension enabled against the same theme without it. If your app adds more than a few points of Largest Contentful Paint delay, expect merchants to notice, because storefront speed audits flag app assets by name.
Development workflow
shopify app dev serves your extension into a development store, hot-reloading Liquid and assets as you edit. You preview blocks in the actual theme editor, which is the honest test environment: real section widths, real theme typography, real merchant controls. If you want to move faster on the Liquid itself, we have had good results pairing the CLI dev loop with AI assistance; the workflow in the Claude Code theme and Liquid build guide applies to extension blocks unchanged.
Test on a development store with several themes installed, not just Dawn. Free development stores come with a Partner account; if you are starting from zero, create one through Shopify and install a couple of popular third-party themes, because that is where block styling assumptions go to die.
Deploying is the same versioned flow as every extension type:
shopify app deploy
Each deploy creates a new app version; merchants get updated block code automatically while their settings and placements persist.
Design rules that keep you out of trouble
- Inherit, do not impose. Use CSS custom properties and relative units so blocks adopt the theme's typography and spacing. A block that ships its own font stack looks broken in every theme but yours.
- Namespace everything. Prefix classes and IDs (
axr-above) to avoid colliding with theme CSS. - Render server-side when possible. Liquid output beats client-side hydration for speed and SEO. Reserve JS for genuine interactivity.
- Expect empty states. Blocks render with default settings the moment they are added. Defaults should look presentable, not like placeholder debris.
- Do not assume placement. Merchants will put your block in a 280px sidebar and in a full-width section on the same day. Test both.
A real request: "put the stars under the product title"
Here is how the decision plays out on an actual merchant ask. A store running Dawn wants your review badge directly beneath the product title on product pages. The title lives inside the theme's main product section, so this is an app block: the merchant opens the product template in the theme editor, clicks Add block inside that section, picks your badge from the Apps list, and drags it under the title. Done in a minute, no code, and the placement survives theme updates.
Now the harder version: the same request from a merchant on a vintage theme with no JSON templates. Your block cannot be placed, and you have three options in descending order of appeal. Ask the merchant to update to an OS 2.0 version of their theme, which many resist mid-season. Ship the badge as an app embed that finds an anchor element with JavaScript, which works but reintroduces fragile DOM selectors that break when the theme changes. Or decline the precise placement and offer a floating variant. Most mature apps ship option two as a labeled fallback with a selector setting, and are honest in onboarding that OS 2.0 themes get the first-class experience. What you should not do is write into theme files programmatically; that road leads back to the orphaned-code era the framework exists to end.
Troubleshooting: common failure modes
Your block is missing from the Add block list. Either the theme is vintage, or the specific section does not accept app blocks (its schema must include @app among its block types). Test in a section that ships with the theme, like the main product section, before assuming your extension is broken.
Styles collapse in one particular theme. Aggressive theme CSS is usually the cause: resets, !important rules, or deep descendant selectors bleeding into your markup. Scope every rule under your namespace prefix, avoid styling bare elements, and raise specificity deliberately rather than escalating with your own !important war.
The embed stopped working after a theme switch. App embed activation is stored per theme, inside that theme's settings. When the merchant publishes a new theme, your embed starts disabled there. Detect the condition (your embed's beacon goes quiet while the app is still installed) and prompt the merchant with the deep link to re-enable, or you will eat a support ticket instead.
Your JavaScript runs three times on one page. Merchants can add the same block to a page multiple times. Initialize per block instance, keyed off {{ block.id }} in your markup, and make initialization idempotent so double-loading is harmless.
Asset edits are not showing up. Layered caching: the CDN, the theme editor preview, and the browser each hold copies. Confirm the deployed version actually changed with shopify app versions list, then hard-reload the preview before debugging further.
The block renders blank with no error. Invalid schema JSON (a trailing comma is the classic) silently disables the block. Run the schema through a JSON validator; Liquid will not warn you.
The conversion math merchants are doing
Merchants evaluate storefront apps with a sharper eye than they did a few years ago, because speed reports name the offending assets and slow product pages waste every paid click sent at them. A widget that adds real value but also adds half a second of render delay is often a net negative on ad-driven traffic. Keep your extension's shipped bytes small enough that the answer to "is this app worth its weight" is never close. That framing sounds harsh, but it is the exact calculation running in a merchant's head when your app shows up in a speed audit, and it decides your churn rate as surely as your feature set does.
Next step
Scaffold the extension and get one block rendering in a development store today: shopify app generate extension --template theme_app_extension, then shopify app dev, then add the block in the theme editor. Once you have seen your Liquid render inside three different themes, port your app's most-requested storefront element into a block and ship it; if you still have a ScriptTag in production, that is the one to replace first.