Developer guide
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.
What this article is really about
The core architectural question is not “How do I make my app block talk to Rails?” The real question is “What data and logic should the storefront need at render time, and what should have been decided earlier by the app?”
Teams get this wrong all the time. They build a perfectly decent embedded app in Rails, then treat the theme extension like a tiny React frontend that must call home for every opinion it has. The result is predictable: extra latency, brittle storefront rendering, more support tickets, more cache confusion, and that special form of pain where the merchant says, “The block is there, but nothing shows,” and you begin bargaining with whatever deity owns CDN invalidation.
Shopify’s extension model is trying to push you toward a better split. Theme app extensions are Shopify-hosted, versioned, and exposed in the theme editor. They do not edit theme code, and they let merchants add app blocks and app embeds without manual Liquid surgery. That is not just a nicer DX story. It is an architectural hint. Let Shopify handle storefront delivery surfaces. Let Rails handle app state, admin-side workflows, background jobs, and durable business logic.
“Theme app extensions automatically expose your app in the theme editor.”
The working rule
If the value is stable enough to synchronize ahead of time, do not fetch it from Rails during storefront rendering. If the value is a real-time app decision, keep the endpoint narrow and purpose-built.
Use Rails as the control plane
The clean mental model is this:
- Shopify serves the extension assets and hosts the merchant-facing theme placement surface.
- Rails owns configuration UX, persistence, Admin API mutations, webhook reactions, and jobs.
- The extension renders the smallest possible storefront surface that still feels native.
This is a control-plane versus delivery-surface split. Rails is the place where the merchant configures the feature, where app plans are evaluated, where background syncs happen, and where app-owned rules live. The theme extension is where shoppers see the result.
That distinction matters because Shopify hosts extension assets on its CDN and versions them with the rest of the app release flow. If your design assumes that Rails must answer every block render, then you threw away one of the platform’s biggest gifts and replaced it with your own custom outage generator.
In practice, Rails should own things like:
- Merchant onboarding and settings screens inside the embedded app.
- Admin GraphQL writes to metafields, metaobjects, or other Shopify resources.
- Per-installation plan state, feature flags, and app policy decisions.
- Webhook-driven synchronization when products, collections, or themes change.
- Background work such as reindexing, resyncing content, or generating app-owned outputs.
The extension should own things like:
- Rendering block markup and embed markup.
- Reading extension settings and theme-connected data.
- Running minimal storefront JavaScript for the feature itself.
- Calling Rails only when the storefront genuinely needs app-owned logic or fresh signed output.
| Concern | Own it in Rails | Own it in the extension | Main caution |
|---|---|---|---|
| Merchant settings | Yes | No | Do not rebuild a settings UI inside theme code if the app admin already owns it. |
| Shopper-facing block markup | No | Yes | Keep markup simple enough that Liquid and small JS can carry it. |
| App plan or entitlement logic | Yes | Only as synchronized visibility hints | Do not trust theme-side settings as your source of billing truth. |
| Long-running syncs | Yes | No | Never do sync work during storefront requests. |
| Per-page floating widget | Mostly no | Yes, as an app embed | Only call Rails if the widget needs live app-owned decisions. |
Shopify is pretty explicit about the intended shape here. New apps that integrate with online store themes should use theme app extensions, and the old pattern of modifying theme code or spraying script tags into storefronts is the thing you are meant to leave behind.
“If your app integrates with a Shopify theme ... you must use theme app extensions.”
Move theme-safe state out of runtime requests
Most performance wins here are architectural, not micro-optimizations. Before you profile anything, ask one question: does this block need a live backend response at all?
In many apps, the answer is no. The block needs a title, a label, a list of links, a boolean feature state, a product-specific badge, a merchant-edited explanation, or a collection of data that your app already knows in advance. None of that should require the shopper’s browser to hit Rails on every page view.
Instead, push storefront-safe state into places the extension can render directly:
- Extension settings for merchant-controlled presentation choices.
- Resource metafields for data attached to products, collections, pages, or other Shopify resources.
- Metaobjects when the content is structured, reusable, and editor-friendly.
- App-data metafields when the data belongs to the app installation and is used for extension behavior such as conditional visibility.
Shopify’s docs are unusually clear on two points that matter a lot here. First, app blocks can use autofill resource settings and dynamic sources, which makes them a very nice fit for product-, collection-, or article-scoped content. Second, app embeds do not support dynamic sources, because they only get the global Liquid scope for the page. That means the “just use an app embed for everything” shortcut is not clever. It is how you end up rebuilding missing context in JavaScript at 2:13 a.m.
“App embed blocks can’t point to dynamic sources.”
A very common Rails pattern is to treat your own database as the source of truth, then synchronize a storefront-safe projection into Shopify. That projection can be much smaller than your full internal model. For example:
# app/jobs/theme_extension/product_badge_sync_job.rb
class ThemeExtension::ProductBadgeSyncJob < ApplicationJob
queue_as :default
def perform(shop_id, product_id)
shop = Shop.find(shop_id)
badge = ProductBadgeBuilder.new(shop: shop, product_id: product_id).call
ShopifyAdmin::GraphqlClient.for(shop).query(
<<~GRAPHQL,
mutation SetProductBadge($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
}
userErrors {
field
message
}
}
}
GRAPHQL
variables: {
metafields: [
{
ownerId: shop.shopify_product_gid(product_id),
namespace: "$app",
key: "shipping_promise",
type: "single_line_text_field",
value: badge.shipping_promise.to_s
},
{
ownerId: shop.shopify_product_gid(product_id),
namespace: "$app",
key: "preorder_state",
type: "single_line_text_field",
value: badge.preorder_state.to_s
}
]
}
)
end
endThen your app block reads the synchronized storefront-facing data instead of asking Rails to recompute it during the page request:
{% liquid
assign shipping_promise = product.metafields.app.shipping_promise.value
assign preorder_state = product.metafields.app.preorder_state.value
%}
{% if shipping_promise != blank %}
<div class="addora-badge">
<strong>{{ shipping_promise }}</strong>
{% if preorder_state != blank %}
<span>{{ preorder_state }}</span>
{% endif %}
</div>
{% endif %}
{% schema %}
{
"name": "Shipping promise badge",
"target": "section",
"settings": []
}
{% endschema %}In the example above, the values are declared as app-owned product metafields in the app-reserved namespace, so Liquid can read them from product.metafields.app. Keep that contract explicit in your app docs and tests. The important part is the architectural move: synchronize first, render second.
Choose the right storefront data surface
The simplest way to avoid bad architecture is to classify your data correctly. Most teams do not have a runtime problem. They have a placement problem.
| Data shape | Best surface | Why it fits | What people get wrong |
|---|---|---|---|
| Pure presentation choice like title, color, spacing | Extension settings | Merchant edits it in the theme editor, right where placement happens. | Storing display-only choices in your Rails DB and then trying to mirror them back. |
| Product- or collection-scoped app output | Resource metafields | Close to the Shopify resource and often renderable with dynamic sources. | Calling Rails to look up something that already belongs next to the product. |
| Structured reusable content | Metaobjects | Strong editorial model, reusable references, theme-friendly. | Stuffing a JSON blob into one metafield because “it was faster.” It was not faster. It was just earlier. |
| Per-install app flags or conditional visibility state | App-data metafields | Tied to the app installation and useful for extension behavior. | Putting secrets there and pretending they are a proper secret manager. |
| Fresh shopper-specific decision | Rails endpoint or app proxy | Requires app-owned logic at request time. | Generalizing one targeted endpoint into an entire unofficial storefront API. |
App-data metafields are particularly useful in one narrow but important case: extension
visibility and per-install flags. Shopify supports available_if on app
blocks and app embeds, and the value is driven by an app-data metafield that can be
accessed via the Liquid app object. That is a great fit for “merchant is
on Pro plan” or “feature has completed onboarding,” and a terrible fit for “here are my
API credentials.”
{% schema %}
{
"name": "Loyalty teaser",
"target": "section",
"available_if": "{{ app.metafields.flags.loyalty_enabled }}",
"settings": [
{
"type": "text",
"id": "headline",
"label": "Headline",
"default": "Rewards available"
}
]
}
{% endschema %}If your content is repeated, nested, or editorially managed, metaobjects are often a better fit than cramming structured data into one giant metafield. Dynamic sources can bind theme settings to metafields and metaobjects, which is a much healthier long-term shape than sending custom JSON payloads from Rails and hoping future-you remembers the schema.
Also note the hidden strategy implication: app blocks and app embeds are not symmetric. App blocks are placement-aware and dynamic-source-friendly. App embeds are broad, floating, global, and good for things like trackers, badges, or widgets injected at the head or body level. Pick the surface that matches the job. Do not pick the one that feels easiest at 4 p.m. and then discover at 11 p.m. that it has no resource context.
When a theme extension should call Rails
A theme extension should call Rails when the storefront needs app-owned logic, not when it merely needs app-owned configuration.
Good reasons to call Rails include:
- Signed personalization or gated shopper experiences.
- Fresh campaign, segmentation, or entitlement decisions that cannot be materialized ahead of time.
- Time-sensitive lookups where staleness would be incorrect, not merely slightly annoying.
- Lightweight write actions such as event capture, preference toggles, or claim flows.
Weak reasons include:
- Fetching static merchant settings.
- Looking up content that could live in a metafield or metaobject.
- Deciding whether the extension should be visible when
available_ifalready solves it. - Rendering the same small JSON blob for every shopper on every page because “the backend already has it.”
When you do need a request path from storefront to Rails, keep it narrow. Build a single-purpose endpoint for a single-purpose capability. Resist the urge to expose your app’s entire internal model through one “theme API” controller. Those endpoints become weirdly privileged, poorly cached, and difficult to secure because they mix anonymous storefront access with app-owned business rules.
Shopify app proxies are often the right shape for these targeted storefront endpoints.
They let requests hit a storefront URL on the shop domain and proxy them to your app.
But they come with real constraints. Shopify allows only one proxy root per app, and
app proxies do not support cookies because Shopify strips both the Cookie
header on the way in and Set-Cookie on the way out.
“App proxies don’t support cookies.”
That means your Rails endpoint design should assume stateless request validation, not session cookies and not “we will just use the normal logged-in app user.” The storefront is not your embedded admin.
# config/routes.rb
scope :apps do
get "addora/widget-state", to: "shopify/app_proxy/widget_state#show"
end# app/controllers/shopify/app_proxy/widget_state_controller.rb
class Shopify::AppProxy::WidgetStateController < ActionController::Base
protect_from_forgery with: :null_session
def show
shop_domain = params[:shop]
shop = Shop.find_by!(shopify_domain: shop_domain)
verifier = Shopify::AppProxySignatureVerifier.new(
secret: ENV.fetch("SHOPIFY_API_SECRET"),
params: request.query_parameters
)
return head :unauthorized unless verifier.valid?
payload = ThemeWidgetStateBuilder.new(
shop: shop,
product_handle: params[:product_handle],
customer_id: params[:logged_in_customer_id]
).call
expires_in 30.seconds, public: true
render json: payload
end
endThe point is not the exact controller shape. The point is that this endpoint has one job, a clear trust boundary, and explicit cache behavior. That ages much better than a generic endpoint that starts as “one little JSON route” and ends as a second API you now have to maintain forever.
A Rails implementation pattern that stays boring in production
“Boring in production” is high praise. It means the merchant can install, configure, preview, and publish without your team improvising emergency theology around theme state.
A solid Rails-backed pattern usually looks like this:
- The merchant configures the feature inside your embedded app.
- Rails persists canonical app state in your database.
- Rails synchronizes the storefront-safe projection into Shopify using metafields or metaobjects.
- The extension renders from those native surfaces.
- Optional: Rails exposes one or two narrow storefront endpoints for the parts that are truly dynamic.
For per-installation flags that drive extension behavior, query the current app
installation, then write an app-data metafield. That is a clean way to power
available_if, feature gating, or installation-complete status.
# app/services/shopify/app_installation_flags_sync.rb
class Shopify::AppInstallationFlagsSync
CURRENT_APP_INSTALLATION = <<~GRAPHQL
query {
currentAppInstallation {
id
}
}
GRAPHQL
METAFIELDS_SET = <<~GRAPHQL
mutation SetFlags($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields {
id
namespace
key
value
}
userErrors {
field
message
}
}
}
GRAPHQL
def initialize(shop:)
@shop = shop
@client = ShopifyAdmin::GraphqlClient.for(shop)
end
def call
installation_id = @client.query(CURRENT_APP_INSTALLATION)
.dig("data", "currentAppInstallation", "id")
response = @client.query(
METAFIELDS_SET,
variables: {
metafields: [
{
ownerId: installation_id,
namespace: "flags",
key: "onboarding_complete",
type: "boolean",
value: @shop.onboarding_complete?.to_s
},
{
ownerId: installation_id,
namespace: "flags",
key: "premium_widgets_enabled",
type: "boolean",
value: @shop.premium_widgets_enabled?.to_s
}
]
}
)
errors = response.dig("data", "metafieldsSet", "userErrors") || []
raise SyncError, errors.map { |e| e["message"] }.join(", ") if errors.any?
end
endThen your onboarding UI can send the merchant directly into the theme editor with a deep link, instead of making them play hide-and-seek in the customizer. Shopify supports deep linking for both app blocks and app embeds, and the exact URL shape differs for each.
# app/helpers/theme_editor_link_helper.rb
module ThemeEditorLinkHelper
def app_embed_activation_url(shop_domain:, api_key:, handle:, template: "product")
"https://#{shop_domain}/admin/themes/current/editor?" \
"context=apps&template=#{template}&activateAppId=#{api_key}/#{handle}"
end
def app_block_add_url(shop_domain:, api_key:, handle:, template: "product", target: "newAppsSection")
"https://#{shop_domain}/admin/themes/current/editor?" \
"template=#{template}&addAppBlockId=#{api_key}/#{handle}&target=#{target}"
end
endThis split also makes operational debugging much saner:
- If the merchant changed a setting, inspect your Rails state and the sync job.
- If the data exists in Shopify but not on the page, inspect placement, template support, and Liquid usage.
- If the dynamic endpoint is failing, inspect that endpoint in isolation instead of spelunking through the entire app backend.
In other words, each layer has a boring failure mode. That is good architecture. The scary architecture is the one where every failure looks the same because everything is coupled to everything else.
Operational patterns that age well
The teams that stay happy with theme app extensions are not necessarily the ones with the fanciest code. They are the ones with the clearest contracts.
Version your data contracts. If your extension expects a specific metafield shape or metaobject schema, version it. Old assets and old synchronized data should fail predictably, not artistically.
Treat extension activation state as a hint, not eternal truth. A merchant can disable an app embed or remove a block. If a business-critical workflow depends on storefront presence, verify the condition when it matters.
Synchronize off the request path. Use jobs and webhook-driven repair loops. Do not update metafields as part of a shopper page request unless you enjoy inventing race conditions for free.
Keep onboarding in the embedded app. Setup, plan upgrades, and diagnostics belong in the admin app. Shopper-facing execution belongs in the extension.
Prefer app blocks for resource-aware UI. Shopify explicitly notes that app blocks can use autofill resource settings and dynamic sources. Use that instead of reconstructing context in JavaScript.
Use app embeds for global or floating behavior. Great for analytics, floating widgets, and broad storefront script behavior. Not great for resource-scoped rendering that wants rich dynamic-source support.
Do not hide secrets in Shopify content surfaces. Shopify’s metafield docs explicitly caution that private app data generally belongs in your secure database or secret management, not in storefront-adjacent config surfaces.
One more practical point: keep your extension names short and human. Shopify recommends keeping block and embed names under 25 characters so they fit well in the theme editor. This sounds minor until you have five similarly named blocks and the merchant starts guessing which one is yours. Nobody wants their information architecture to resemble a police lineup.
The biggest long-term mistake is blurring “admin app state” and “storefront state” until neither has a clear source of truth. Store your canonical truth in Rails. Publish the minimum storefront projection into Shopify. Let the extension read that projection. Add a runtime call only where the business requirement truly demands it.
Best internal links
Sources and further reading
Shopify Dev: About theme app extensions
Shopify Dev: Configure theme app extensions
Shopify Dev: Migrate to theme app extensions
Shopify Dev: About app proxies and dynamic data
Shopify Dev: Authenticate app proxies
Shopify Dev: About metafields, including app-owned and app-data metafields
Shopify Dev: currentAppInstallation query
Shopify Dev: Dynamic sources
FAQ
Should a theme app extension call my Rails app on every page load?
Usually no. Most extension state should be pre-synchronized into settings, metafields, or metaobjects. Runtime calls are for genuinely dynamic app-owned behavior, not ordinary configuration.
When should I choose an app block instead of an app embed?
Choose an app block when the merchant should place, reorder, and configure visible UI inside a section, especially when dynamic sources help. Choose an app embed for floating UI, tracking, head tags, or body-level script behavior.
Is app proxy the normal way to feed data into a theme app extension?
No. App proxies are useful, but they should be the exception, not the default architecture. They are best for targeted storefront endpoints, not for replacing theme-native data surfaces.
Where should private per-install settings live?
In your Rails database first. App-data metafields are useful for per-installation configuration that the extension or conditional visibility needs, but they are not a secret vault for credentials.
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.
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.