Developer guide
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.
Decide what kind of truth you need
Teams usually ask one vague question:
is the extension enabled?
That question is doing too much work and doing it badly.
In practice, there are at least three different questions hiding inside it:
Onboarding truth: should the embedded app show a setup banner, success state, or “go enable this” prompt right now?
Operational truth: does the live storefront theme currently contain the thing you think it contains?
Rendering truth: will this extension actually render for the specific page, resource, template, and conditions in front of a shopper?
These are related, but they are not interchangeable. Treating them as interchangeable is how you end up with a dashboard proudly announcing “installed” while the storefront is as empty as a startup’s staging database on a Friday evening.
“The App API provides information about the app and the status of its extensions.”
The working rule
Choose the truth source based on the decision you need to make. Do not ask one generic boolean to act as onboarding state, storefront audit, and rendering proof at the same time.
The Shopify truth sources available today
Shopify now gives you two serious detection paths, and they are both valid:
Embedded-app status via App Bridge: fast, merchant-facing, and ideal for setup UX.
Theme inspection: slower, backend-oriented, but better when you need server-side or offline truth.
Shopify’s App API now returns theme app extension information, including theme app
blocks and app embeds, with per-activation status values of
active, available, and unavailable. That is a
major improvement for embedded onboarding flows because the admin surface can ask
Shopify directly instead of turning your backend into a haunted JSON archaeology team.
At the same time, Shopify’s theme app extension docs still explicitly document theme file inspection for detecting app blocks and app embeds. That remains useful whenever the browser is not the source of truth you want, such as support tooling, cron-driven audits, or checks that must run before a merchant opens the app.
| Truth source | Best for | Main strength | Main caution |
|---|---|---|---|
App Bridge shopify.app.extensions() | Embedded onboarding and setup UI | Fast and merchant-visible | Not the right tool for deep offline auditing |
| Theme files via Admin API | Backend verification and support tooling | Works outside the embedded app | State interpretation is your problem now, enjoy |
| Storefront callback from the extension itself | Telemetry and runtime confirmation | Proves the extension executed | Only fires when storefront traffic exists |
There is also an important product distinction here:
App blocks are added into theme sections and depend on compatible templates and section schemas.
App embeds inject into
head,body, orcompliance_head, and Shopify says they are deactivated by default after install.
So even before detection, the activation model is already different. That means your state model should be different too.
“By default, app embed blocks are deactivated after an app is installed.”
Use App Bridge extension status first
If your goal is to decide what setup UI to show inside the embedded app, App Bridge is the default winner.
Shopify’s App API exposes theme app extensions and their activation records. For theme app extensions, the important bits are:
type, which includestheme_app_extensionnested activation entries with a
handle,name,target, andstatustheme-specific placements under nested
activations, includingthemeId
The subtle but important part is that target already tells you what kind
of thing you are looking at:
sectionmeans app blockhead,body, orcompliance_headmeans app embed
That means you do not need one giant regex blob named
detectIfStuffMaybeEnabledSomehow(). You can model blocks and embeds
explicitly, which is both cleaner and less likely to get future-you cursed at in a
code review.
React example for embedded setup state
import {useEffect, useMemo, useState} from 'react';
import {useAppBridge} from '@shopify/app-bridge-react';
type ThemeActivationStatus = 'active' | 'available' | 'unavailable';
type ThemeActivation = {
handle: string;
name: string;
target: 'section' | 'head' | 'body' | 'compliance_head';
status: ThemeActivationStatus;
activations: Array<{
target: string;
themeId: string;
}>;
};
type ExtensionInfo = {
handle: string;
type: 'ui_extension' | 'theme_app_extension';
activations: ThemeActivation[];
};
function classifyActivation(target: ThemeActivation['target']) {
return target === 'section' ? 'app_block' : 'app_embed';
}
export function ThemeExtensionStatusCard() {
const shopify = useAppBridge();
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
setLoading(true);
const result = (await shopify.app.extensions()) as ExtensionInfo[];
if (!cancelled) setExtensions(result);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [shopify]);
const themeActivations = useMemo(() => {
return extensions
.filter((extension) => extension.type === 'theme_app_extension')
.flatMap((extension) =>
extension.activations.map((activation) => ({
extensionHandle: extension.handle,
kind: classifyActivation(activation.target),
...activation,
})),
);
}, [extensions]);
const appEmbeds = themeActivations.filter((a) => a.kind === 'app_embed');
const appBlocks = themeActivations.filter((a) => a.kind === 'app_block');
if (loading) return <p>Loading extension status...</p>;
if (error) return <p>Could not load extension status: {error}</p>;
return (
<div>
<h2>Theme setup</h2>
<h3>App embeds</h3>
<ul>
{appEmbeds.map((embed) => (
<li key={`${embed.extensionHandle}:${embed.handle}`}>
{embed.name} ({embed.handle}) → {embed.status}
</li>
))}
</ul>
<h3>App blocks</h3>
<ul>
{appBlocks.map((block) => (
<li key={`${block.extensionHandle}:${block.handle}`}>
{block.name} ({block.handle}) → {block.status}
</li>
))}
</ul>
</div>
);
}This is the right place to drive onboarding cards like:
- Embed active: great, continue
- Embed available: show deep link to activate
Embed unavailable: surface that the block exists but is not currently available, often because of an
available_ifcondition
Shopify documents unavailable for theme app blocks or embeds that exist
but are disabled, such as via available_if. That means
unavailable is not the same as “merchant forgot setup”. Sometimes it
means “your app logic said no”.
Use App Bridge with deep links, not with vibes
If the status says an embed is available but not active, link the merchant straight to
the right activation flow. Shopify documents an app embed deep link using
activateAppId:
export function buildAppEmbedDeepLink({
shop,
template = "index",
apiKey,
handle,
}: {
shop: string;
template?: string;
apiKey: string;
handle: string;
}) {
const params = new URLSearchParams({
context: "apps",
template,
activateAppId: `${apiKey}/${handle}`,
});
return `https://${shop}/admin/themes/current/editor?${params.toString()}`;
}For app blocks, use Shopify’s documented addAppBlockId flow and target a
sensible location such as a new Apps section on the default product template.
The default in 2026
For setup UX inside the embedded app, use App Bridge first and reserve theme inspection for the places where frontend truth is not enough.
When server-side theme inspection still makes sense
Theme inspection still matters a lot. It just should not be your first instinct for every onboarding banner.
Use server-side inspection when you need one of these:
- support tooling that runs without the merchant opening the app
- scheduled audits
- draft-theme checks
- pre-publish verification
- historical or cross-theme analysis
- backend-only workflows and webhooks
Shopify’s theme extension docs still describe detection through theme files. For app
blocks, the reference is in template JSON. For app embeds, the reference is in
config/settings_data.json, and Shopify explicitly notes that an embed
remains there after first enablement with disabled: true if later turned
off.
“App embed blocks appear in settings_data.json.”
There is one important implementation update that a lot of older articles miss: Shopify’s docs still mention the Asset REST API for detection, but Admin GraphQL got theme and theme-file parity starting in API version 2024-10. For new backend reads, GraphQL is usually the cleaner default because you can fetch theme metadata and file content through the same API family.
That does not mean REST is wrong. It means you no longer need to reach for the older path by default when your real job is read-only inspection.
What to inspect for each case
| Extension type | Primary file(s) | What you actually check | Main trap |
|---|---|---|---|
| App embed | config/settings_data.json | Block entry exists and disabled is not true | Presence alone is not activation |
| App block | templates/*.json | Relevant section or Apps section contains your block type | Installed somewhere is not rendered everywhere |
Prefer GraphQL reads for new backend implementations
Shopify’s Admin GraphQL themes query can find the published theme with
role MAIN. The theme query can then fetch theme files and
file content. Shopify documents up to 50 filenames per request and up to 2500 files in
a fetch, subject to payload limits.
query MainTheme {
themes(first: 1, roles: [MAIN]) {
nodes {
id
name
role
}
}
}query ThemeFilesForDetection($themeId: ID!) {
theme(id: $themeId) {
id
name
role
files(
filenames: ["config/settings_data.json", "templates/*.json"]
first: 50
) {
nodes {
filename
body {
... on OnlineStoreThemeFileBodyText {
content
}
}
}
}
}
}Two cautions here:
Do not blindly fetch every file in a giant merchant theme on every request just because you technically can.
For app blocks, do not confuse “block exists in some template JSON” with “this shopper will see it on this page”.
A Rails-friendly server-side detection pattern
A clean Rails pattern is to separate detection into two service objects:
ThemeExtensionSnapshotFetcherfor fetching and parsing theme filesThemeExtensionDetectorfor interpreting those files into business meaning
That keeps your Shopify API transport, JSON parsing, and product logic from becoming one big procedural lasagna.
Step 1: fetch the published theme and relevant files
class ThemeExtensionSnapshotFetcher
MAIN_THEME_QUERY = <<~GRAPHQL
query {
themes(first: 1, roles: [MAIN]) {
nodes {
id
name
role
}
}
}
GRAPHQL
THEME_FILES_QUERY = <<~GRAPHQL
query($themeId: ID!, $filenames: [String!]) {
theme(id: $themeId) {
id
name
role
files(filenames: $filenames, first: 50) {
nodes {
filename
body {
... on OnlineStoreThemeFileBodyText {
content
}
}
}
}
}
}
GRAPHQL
def initialize(admin_client:)
@admin_client = admin_client
end
def call
theme = fetch_main_theme
files = fetch_files(theme.fetch("id"))
{
theme: theme,
files: index_files(files)
}
end
private
attr_reader :admin_client
def fetch_main_theme
response = admin_graphql(MAIN_THEME_QUERY)
response.dig("data", "themes", "nodes")&.first ||
raise("No published theme found")
end
def fetch_files(theme_id)
response = admin_graphql(
THEME_FILES_QUERY,
{
themeId: theme_id,
filenames: [
"config/settings_data.json",
"templates/*.json"
]
}
)
response.dig("data", "theme", "files", "nodes") || []
end
def index_files(files)
files.each_with_object({}) do |file, hash|
hash[file.fetch("filename")] = file.dig("body", "content")
end
end
def admin_graphql(query, variables = {})
# Replace with your actual Shopify GraphQL client
admin_client.query(query: query, variables: variables)
end
endStep 2: detect app embeds correctly
App embeds are the easier case because Shopify documents their location and lifecycle more clearly.
class AppEmbedDetector
def initialize(settings_data_json:, app_slug:, embed_handle:)
@settings_data_json = settings_data_json
@app_slug = app_slug
@embed_handle = embed_handle
end
def call
current = parsed.fetch("current", {})
blocks = current.fetch("blocks", {})
matching_block = blocks.values.find do |block|
type = block["type"].to_s
type.include?("shopify://apps/#{app_slug}/blocks/#{embed_handle}/")
end
return { installed_once: false, active: false } if matching_block.nil?
{
installed_once: true,
active: matching_block["disabled"] != true
}
end
private
attr_reader :settings_data_json, :app_slug, :embed_handle
def parsed
@parsed ||= JSON.parse(settings_data_json)
end
endThe important bit is the distinction between:
installed_once: false, which means no trace foundinstalled_once: true, active: false, which means the embed was enabled before but is currently disabled
If you only check presence, you will misreport disabled embeds as active. That is not a hypothetical. That is a real class of bug, and it will make your onboarding UI gaslight merchants.
Step 3: detect app blocks without overselling certainty
App blocks are more annoying because “the block exists in a template file” is not equivalent to “the merchant has finished setup for the page that matters”.
class AppBlockDetector
def initialize(template_files:, app_slug:, block_handle:)
@template_files = template_files
@app_slug = app_slug
@block_handle = block_handle
end
def call
matches = template_files.filter_map do |filename, content|
next if content.blank?
parsed = JSON.parse(content)
next unless contains_block_type?(parsed)
{
filename: filename,
installed: true
}
rescue JSON::ParserError
nil
end
{
installed_somewhere: matches.any?,
templates: matches
}
end
private
attr_reader :template_files, :app_slug, :block_handle
def contains_block_type?(node)
case node
when Hash
node.any? do |key, value|
(key == "type" && value.to_s.include?(expected_type_fragment)) ||
contains_block_type?(value)
end
when Array
node.any? { |value| contains_block_type?(value) }
else
false
end
end
def expected_type_fragment
"shopify://apps/#{app_slug}/blocks/#{block_handle}/"
end
endNotice the return value is installed_somewhere, not
definitely_visible_everywhere_forever. That is not me being precious with
naming. It is the difference between a useful backend fact and a support nightmare.
Step 4: turn raw detection into product language
class ThemeExtensionDetector
def initialize(snapshot:, app_slug:, embed_handle:, block_handle:)
@snapshot = snapshot
@app_slug = app_slug
@embed_handle = embed_handle
@block_handle = block_handle
end
def call
files = snapshot.fetch(:files)
embed = AppEmbedDetector.new(
settings_data_json: files["config/settings_data.json"].to_s,
app_slug: app_slug,
embed_handle: embed_handle
).call
template_files = files.select { |filename, _| filename.start_with?("templates/") }
block = AppBlockDetector.new(
template_files: template_files,
app_slug: app_slug,
block_handle: block_handle
).call
{
theme_name: snapshot.dig(:theme, "name"),
app_embed: embed,
app_block: block
}
end
private
attr_reader :snapshot, :app_slug, :embed_handle, :block_handle
endThis gives your app honest output:
- App embed: never enabled, enabled now, or enabled before but off
- App block: found in these templates, or not found
Honest output beats magical output. Magical output is how support tickets reproduce in exactly one merchant theme named “Dawn copy copy FINAL for real”.
Edge cases that make your boolean lie
This is where most detection systems stop being architecture and start being folklore. Here are the traps that actually matter.
1. Published-theme truth is not draft-theme truth
Shopify staff said in February 2026 that App Bridge extension status for theme app extension checks currently supports the published theme. That is fine for many setup flows, but it is not enough for draft-theme workflows, preview tooling, or “are we ready to publish?” checks.
If unpublished themes matter, inspect the theme you care about directly from the backend.
2. App embed presence is not current activation
Shopify documents that once an app embed has been enabled for the first time, it stays
in settings_data.json even after being disabled, with
disabled: true. So “found in settings_data.json” is not the same as
“currently active”.
3. App block installed somewhere is not storefront rendering proof
App blocks live inside theme sections and templates. A block can be present in:
- the wrong template
- a non-default template
- a template not assigned to the resource you care about
- a section whose own rendering conditions are not met
So if your product team asks, “did the merchant install the block?”, ask back: “installed where?”
4. available_if can make your extension unavailable
Shopify supports available_if for app blocks and app embeds through an
app-data metafield boolean. That means your extension can exist but be unavailable.
If you flatten that into a generic falsey “not installed” state, your setup messaging
becomes misleading.
5. Multiple handles mean multiple truths
Many apps ship more than one block or embed. If your code says “some theme app extension is active”, that may be technically true and product-wise useless.
Detect by exact handle and exact intent:
badgeblockreviewsblockanalytics-widgetembedconsent-loaderembed
6. Recent platform behavior can lag your assumption
Early 2026 forum threads reported that App Bridge status for app embeds could remain
active after disablement until Shopify fixed a bug, and that
published-theme coverage should be assumed for theme app extension status today. So
treat newly shipped platform behavior with a little healthy paranoia until it proves
itself in production.
Do not persist certainty you did not earn
Store detection as a snapshot with a timestamp, not as eternal truth. Re-check whenever onboarding, support, publishing, or billing logic actually depends on it.
The decision matrix to standardize on
If you want one house rule for the team, use this:
| Question you are answering | Use this source | Why |
|---|---|---|
| Should the embedded app show a setup checklist item? | App Bridge extension status | Fast and native to the merchant workflow |
| Should we deep link the merchant to activate an embed? | App Bridge extension status | Best fit for current-session UX |
| Did the live theme ever enable this embed? | settings_data.json | Can distinguish installed-once from active-now |
| Is this block present in the published theme files? | Theme templates via backend | Works offline and outside the embedded app |
| Is this ready in a draft theme before publish? | Theme files for that theme ID | Published-theme App Bridge checks are not enough |
| Will this render for this specific product or page? | Theme files plus resource/template context | Needs more than existence checks |
And if you want the opinionated version:
Do not call the backend on every admin page load just to guess what App Bridge can now tell you directly.
Do not trust App Bridge alone for draft-theme or offline use cases.
Do not reduce all extension state to one boolean.
Do model app blocks and app embeds separately.
Do return explicit states like
active,available,unavailable,installed_somewhere, andinstalled_once_but_disabled.
The apps that feel polished here are usually not doing something magical. They are just asking a narrower question and answering it honestly.
Best internal links
Sources and further reading
Shopify Dev: App API
Shopify Dev Changelog: App Bridge extension status now includes admin and theme app extensions
Shopify Dev: Configure theme app extensions
Shopify Dev: Admin GraphQL themes query
Shopify Dev: Admin GraphQL theme query
Shopify Dev Changelog: theme and theme file management in Admin GraphQL
Shopify Dev: Verify theme support
Shopify Dev: The Asset API resource (legacy)
Shopify Developer Community: App.extensions() only returning results for published theme
Shopify Developer Community: stale app embed status bug report and fix note
FAQ
Can I detect app embed activation without read_themes?
In the embedded app, yes, App Bridge is now the best first check. For backend or offline verification, you still need theme access patterns such as read_themes and theme file inspection.
Does App Bridge tell me about all themes?
Not reliably for every use case. As of February 2026, Shopify staff said App Bridge extension status currently supports the published theme for theme app extension checks. If draft-theme truth matters, inspect theme files directly.
Should I store enabled state in my database?
Only as a cache or operational hint. Recompute when setup state matters. Theme state can drift, merchants can switch themes, and app availability rules can change.
Why can an app block appear installed but still not render?
Because installed somewhere in a theme is not the same as rendered for the current resource. Template assignment, section compatibility, parent section state, and available_if rules can all prevent output.
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.
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.
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.