Guides

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.

Published by Addora
Updated March 12, 2026
16 min read

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.
ConcernOwn it in RailsOwn it in the extensionMain caution
Merchant settingsYesNoDo not rebuild a settings UI inside theme code if the app admin already owns it.
Shopper-facing block markupNoYesKeep markup simple enough that Liquid and small JS can carry it.
App plan or entitlement logicYesOnly as synchronized visibility hintsDo not trust theme-side settings as your source of billing truth.
Long-running syncsYesNoNever do sync work during storefront requests.
Per-page floating widgetMostly noYes, as an app embedOnly 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
end

Then 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 shapeBest surfaceWhy it fitsWhat people get wrong
Pure presentation choice like title, color, spacingExtension settingsMerchant 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 outputResource metafieldsClose 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 contentMetaobjectsStrong 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 stateApp-data metafieldsTied to the app installation and useful for extension behavior.Putting secrets there and pretending they are a proper secret manager.
Fresh shopper-specific decisionRails endpoint or app proxyRequires 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_if already 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
end

The 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:

  1. The merchant configures the feature inside your embedded app.
  2. Rails persists canonical app state in your database.
  3. Rails synchronizes the storefront-safe projection into Shopify using metafields or metaobjects.
  4. The extension renders from those native surfaces.
  5. 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
end

Then 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
end

This 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

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

Guides

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.

guidesShopify developertheme app extensions