Guides

Developer guide

Shopify Customer Account UI Extensions with a Rails backend

A backend architecture guide for Shopify Customer Account UI Extensions using Rails for app logic, persistence, and API orchestration without turning the extension into a fragile mini-app.

Updated March 12, 2026
15 min read

What this architecture is really solving

The hard part of Customer Account UI Extensions is not rendering a card, a button, or a cute little modal that says “we value you.” The hard part is drawing the line between Shopify-owned runtime concerns and app-owned system concerns.

Shopify’s current customer account runtime is intentionally narrow. Extensions run in an isolated sandbox, can’t touch the real DOM, can’t inject arbitrary HTML, and only get the components and APIs Shopify exposes. They also live under real platform constraints, including a compiled bundle limit of 64 KB. As of API version 2026-01, Shopify recommends Preact and the global shopify object for extension APIs instead of building your whole mental model around a React hook soup that grew up in admin land and never quite adjusted to account land.

“They run in an isolated sandbox.”

That is why the right architecture is not “put the app in the extension.” It is “put the customer-facing interaction in the extension and let Rails remain the adult in the room.” The extension is the kiosk. Rails is the operations desk, audit log, decision engine, recovery plan, and the person who remembers what happened five minutes ago.

The working rule

Keep display logic and lightweight interaction in the extension. Keep durable truth in Rails. If the action must still make sense after the tab closes, it probably belongs to the backend.

One more important boundary: Customer Account UI extensions only work on new customer accounts. Legacy customer accounts do not support them. That sounds obvious until support starts getting tickets that say “your extension disappeared” and the root cause is actually “the merchant never switched account versions.”

What the Rails backend should own

Rails should own anything that needs durability, consistency, authorization, orchestration, or recovery. If a feature can create support debt when it half-succeeds, the backend should be in charge.

ResponsibilityBest ownerWhy
Rendering account UIExtensionIt lives inside Shopify’s customer account surface and has the right target context.
Fetching simple Shopify-owned display dataExtensionCustomer Account API and Storefront API can often serve this directly.
Policy decisions and authorizationRailsThose rules must be consistent across retries, channels, and future UI surfaces.
App-owned records and audit trailsRailsThe extension is not your source of truth and should not become one accidentally.
Admin API calls and stored app tokensRailsSession tokens do not replace app access tokens for Shopify API work.
Retries, jobs, webhooks, reconciliationRailsAnything asynchronous becomes much less funny when it only exists in browser memory.
Secrets, signing, anti-abuse controlsRailsThe extension is a client runtime. Clients are where secrets go to die.

In practice, that means Rails should own features such as return initiation, subscription management rules, order-change eligibility, ticket creation, loyalty ledger updates, external CRM sync, and any action that touches the Admin API or a third-party system.

Shopify’s own docs reinforce the shape of this split. Session tokens are for authenticating calls from the client to your backend. They are not a replacement for authorization, and they are not API access tokens for calling Shopify from your server.

“Session tokens are for authentication, and aren't a replacement for authorization.”

The runtime flow between extension and backend

The clean flow is boring in exactly the right way:

  1. The extension renders inside a specific customer account target.
  2. For Shopify-owned reads, it calls Shopify APIs directly when that is sufficient.
  3. For app-owned actions, it gets a fresh session token and sends it to Rails.
  4. Rails verifies the token, derives shop and actor context, applies policy, persists intent, and either responds immediately or enqueues work.
  5. The extension renders the current state, not a guess about what probably happened.

That flow starts with the extension configuration. In customer account extensions, api_access enables Storefront API access, and network_access enables external network calls such as requests to your Rails backend. Shopify notes that network_access must be requested to publish the extension.

api_version = "2026-01"
 
[[extensions]]
type = "ui_extension"
name = "returns-in-account"
handle = "returns-in-account"
 
[[extensions.targeting]]
target = "customer-account.order-status.customer-information.render-after"
module = "./src/Extension.jsx"
 
[extensions.capabilities]
api_access = true
network_access = true

Here is a lean Preact extension that reads order-scoped data, asks Shopify to require login when needed, and then hands the real write to Rails.

import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useEffect, useState} from 'preact/hooks';
 
export default async () => {
  render(<Extension />, document.body);
};
 
function Extension() {
  const [loading, setLoading] = useState(true);
  const [eligible, setEligible] = useState(false);
  const [message, setMessage] = useState('');
 
  useEffect(() => {
    async function loadEligibility() {
      const response = await fetch('shopify://customer-account/api/2026-01/graphql.json', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          query: `
            query OrderEligibility($id: ID!) {
              order(id: $id) {
                id
                name
                fulfillments(first: 1) {
                  nodes { latestShipmentStatus }
                }
              }
            }
          `,
          variables: {id: shopify.orderId},
        }),
      });
 
      const body = await response.json();
      const hasFulfillment = !!body?.data?.order?.fulfillments?.nodes?.length;
      setEligible(hasFulfillment);
      setLoading(false);
    }
 
    loadEligibility().catch((error) => {
      console.error(error);
      setMessage('Could not load order eligibility.');
      setLoading(false);
    });
  }, []);
 
  async function startReturn() {
    await shopify.requireLogin();
 
    const token = await shopify.sessionToken.get();
    const response = await fetch('https://app.example.com/api/customer_account/return_requests', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
      body: JSON.stringify({
        order_gid: shopify.orderId,
        reason: 'damaged_item',
      }),
    });
 
    const body = await response.json();
    setMessage(body.message || 'Request submitted.');
  }
 
  if (loading) {
    return <s-text>Loading return eligibility…</s-text>;
  }
 
  if (!eligible) {
    return <s-banner heading="Returns unavailable">This order is not eligible yet.</s-banner>;
  }
 
  return (
    <s-stack>
      <s-button onClick={startReturn}>Start a return</s-button>
      {message ? <s-text>{message}</s-text> : null}
    </s-stack>
  );
}

Two things matter here. First, shopify.sessionToken.get() should be called each time you need to talk to your backend. Shopify explicitly says to do that, and the token API returns a cached token when appropriate, so you do not need to build your own tiny expired token museum. Second, the backend endpoint is action-specific. That keeps authorization and validation narrow.

When to use Shopify APIs directly from the extension

A common failure mode is routing every read through Rails because “the backend should own everything.” That is directionally correct for durable logic and completely wrong for simple, local display reads. If Shopify already exposes the data safely in the extension runtime, using that path often gives you less latency, less code, and fewer ways to embarrass yourself in production.

Shopify currently gives customer account extensions three especially useful options:

  • The Customer Account API, which you can access with global fetch().

  • The Storefront API, which you can access with query() or global fetch() when api_access is enabled.

  • Metafield reads and writes via the Customer Account API across customer account extension targets.

Shopify also notes that its custom fetch appends the needed access tokens for supported Shopify APIs, and warns that any GraphQL client you bring into the extension must avoid DOM APIs because the extension runs in a Web Worker. That is a polite documentation way of saying: your usual browser-shaped Apollo pile may not fit through this door.

const response = await fetch('shopify://customer-account/api/2026-01/graphql.json', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `
      query CustomerPreferences {
        customer {
          id
          metafield(namespace: "$app:preferences", key: "support_locale") {
            value
          }
        }
      }
    `,
  }),
});
 
const {data, errors} = await response.json();

The rule I like is simple:

  • Use Shopify APIs directly for display-oriented reads and narrow customer-owned state.
  • Use Rails for app-owned writes, cross-system orchestration, and any action with business policy.
  • Use Rails when you need your app’s own database to be the authority, not just a spectator.

There is a second trap here: pre-authenticated order status. If the customer comes from an order notification link and is not fully logged in, Shopify limits what the extension can access. On that page, customer data and other-order data are restricted, the customer ID is not exposed, and Customer Account API queries must stick to fields marked pre-auth accessible. If you need a broader identity context or need to complete an order action, prompt login with requireLogin() and continue after Shopify returns the customer to the authenticated flow.

“you can't retrieve the customer's ID”

This is the part that breaks “one generic endpoint to rule them all” designs. In pre-auth contexts, your backend often has to authorize using the verified shop plus order context, not by assuming a normal fully authenticated customer identity is always available.

How to verify extension requests in Rails

Verification in Rails should be explicit, tiny, and boring. You are not trying to prove that your exact extension instance made the request. Shopify explicitly warns that a valid session token guarantees the integrity of token claims, not that the whole request originated from your extension. So verify the token, derive trusted context from its claims, and authorize the action server-side.

In Shopify’s React Router stack there is an official helper for public customer account requests. Rails does not get that luxury out of the box, so implement the equivalent claim verification yourself.

# Gemfile
gem "jwt"
# app/services/shopify_customer_account_session.rb
require "uri"
require "jwt"
 
class ShopifyCustomerAccountSession
  VerificationError = Class.new(StandardError)
 
  Result = Struct.new(
    :shop_domain,
    :customer_gid,
    :session_id,
    :raw_claims,
    keyword_init: true,
  )
 
  def self.verify!(token:)
    payload, = JWT.decode(
      token,
      ENV.fetch("SHOPIFY_API_SECRET"),
      true,
      {
        algorithm: "HS256",
        aud: ENV.fetch("SHOPIFY_API_KEY"),
        verify_aud: true,
        verify_expiration: true,
        verify_not_before: true,
        leeway: 5,
      }
    )
 
    shop_domain = normalize_shop_domain(payload.fetch("dest"))
 
    Result.new(
      shop_domain: shop_domain,
      customer_gid: payload["sub"],
      session_id: payload["sid"],
      raw_claims: payload,
    )
  rescue KeyError, JWT::DecodeError => e
    raise VerificationError, e.message
  end
 
  def self.normalize_shop_domain(dest)
    uri = URI.parse(dest)
    uri.host || dest
  rescue URI::InvalidURIError
    dest
  end
end

Then use it in a narrow controller. Notice what is missing: no trusted customer_id parameter, no trusted shop parameter, and no generic “execute action” endpoint that eventually becomes your app’s least favorite security review.

# config/routes.rb
namespace :api do
  namespace :customer_account do
    resources :return_requests, only: [:create]
  end
end
# app/controllers/api/customer_account/base_controller.rb
class Api::CustomerAccount::BaseController < ActionController::API
  before_action :authenticate_customer_account_request!
 
  private
 
  attr_reader :verified_customer_account_session, :current_shop_installation
 
  def authenticate_customer_account_request!
    token = request.authorization.to_s.delete_prefix("Bearer ").presence
    raise ShopifyCustomerAccountSession::VerificationError, "Missing bearer token" unless token
 
    @verified_customer_account_session = ShopifyCustomerAccountSession.verify!(token: token)
    @current_shop_installation = ShopInstallation.find_by!(shop_domain: verified_customer_account_session.shop_domain)
  rescue ShopifyCustomerAccountSession::VerificationError, ActiveRecord::RecordNotFound => e
    render json: {error: e.message}, status: :unauthorized
  end
end
# app/controllers/api/customer_account/return_requests_controller.rb
class Api::CustomerAccount::ReturnRequestsController < Api::CustomerAccount::BaseController
  def create
    result = CustomerAccount::CreateReturnRequest.call(
      shop_installation: current_shop_installation,
      customer_gid: verified_customer_account_session.customer_gid,
      order_gid: params.require(:order_gid),
      reason: params.require(:reason),
    )
 
    render json: {
      return_request_id: result.return_request.id,
      message: "Return request created"
    }, status: :created
  end
end

Your service object should do the real authorization. A good baseline is:

  • Resolve the verified shop installation from dest.
  • Use sub only when the context is fully authenticated and the claim is present.
  • Load the target order server-side and confirm it belongs to the expected shop and customer context.
  • Apply action rules using your own persisted policy, not button visibility alone.
  • Make the write idempotent so retries do not create duplicate work.

That last point matters more than people admit. Extensions retry. Customers double-click. Networks glitch. Support screenshots are forever.

A durable architecture for account-facing features

The most durable pattern is usually a three-layer system:

  1. Extension UI for contextual rendering and customer input.
  2. Thin Rails endpoint for verified request intake.
  3. Existing backend services and jobs for the real work.

That structure keeps customer account features aligned with the rest of your product instead of inventing a second backend hidden inside the extension runtime. You should be able to reuse service objects from admin actions, webhooks, scheduled jobs, and support tooling. If a feature only works when called from the extension, that is usually a smell.

Here is a typical service and job shape for an account-side return request:

# app/services/customer_account/create_return_request.rb
module CustomerAccount
  class CreateReturnRequest
    Result = Struct.new(:return_request, keyword_init: true)
 
    def self.call(...) = new(...).call
 
    def initialize(shop_installation:, customer_gid:, order_gid:, reason:)
      @shop_installation = shop_installation
      @customer_gid = customer_gid
      @order_gid = order_gid
      @reason = reason
    end
 
    def call
      order_policy = CustomerAccount::OrderPolicy.new(
        shop_installation: @shop_installation,
        customer_gid: @customer_gid,
        order_gid: @order_gid,
      )
      order_policy.authorize_return_request!
 
      return_request = ReturnRequest.find_or_create_by!(
        shop_installation: @shop_installation,
        order_gid: @order_gid,
        reason: @reason,
      ) do |record|
        record.customer_gid = @customer_gid
        record.status = "pending"
        record.requested_from = "customer_account_extension"
      end
 
      CreateReturnInShopifyJob.perform_later(return_request.id)
 
      Result.new(return_request: return_request)
    end
  end
end
# app/jobs/create_return_in_shopify_job.rb
class CreateReturnInShopifyJob < ApplicationJob
  queue_as :default
 
  def perform(return_request_id)
    return_request = ReturnRequest.find(return_request_id)
    shop = return_request.shop_installation
 
    ShopifyAdmin::ReturnsClient.new(shop: shop).create_return!(
      order_gid: return_request.order_gid,
      reason: return_request.reason,
    )
 
    return_request.update!(status: "submitted")
  rescue => e
    return_request.update!(status: "failed", failure_message: e.message)
    raise
  end
end

For small contextual interactions, an inline target is enough. For discrete order actions, use order action extensions. For real workflows, use a full-page extension and let the customer breathe. Shopify explicitly supports dedicated pages such as customer-account.order.page.render for order-specific flows, and documents that full-page targets cannot coexist with other targets in the same extension. It also notes that one page per use case is the best default.

Use caseBest target shapeWhy
Loyalty balance, delivery note, simple order hintInline block or static targetSmall contextual UI, often mostly read-only.
“Report an issue”, “Request cancellation”, “Start exchange”Order action extensionAction is clearly tied to the order and Shopify handles login resumption.
Return wizard, claim flow, complex multi-step profile featureFull-page extensionMulti-step flows deserve their own surface instead of modal spaghetti.

A final practical note: if you are sending customers to pre-authenticated order status from email or SMS, Shopify requires careful handling of protected customer data. In general, keep those flows strictly order-scoped until the customer has completed login.

Common production mistakes

  • Building a mini-SPA inside the extension. The runtime is sandboxed, bundle-limited, and optimized for focused UI. This is not where your whole frontend goes to become free.

  • Treating the session token as full authorization. A valid token helps you trust claims, not arbitrary JSON fields you let the client send along for moral support.

  • Routing every read through Rails. That adds latency and complexity when Shopify already exposes the data directly and safely in the extension runtime.

  • Skipping pre-auth rules on order status. Pre-authenticated order status is not the same thing as a fully logged-in account session. Design like it matters, because it does.

  • Using generic endpoints. Action-specific endpoints are easier to validate, audit, test, and reason about than one mega-endpoint that slowly becomes a haunted house.

  • Putting secrets or app-private policy in the extension. The client can keep a secret for about the same length of time a golden retriever can keep a sandwich safe.

  • Ignoring the account version. New customer accounts support these extensions. Legacy customer accounts do not. This should be detected during onboarding, not discovered by a confused merchant on Friday evening.

  • Dragging DOM-dependent GraphQL tooling into a Web Worker runtime. Shopify explicitly warns about this, and the crash reports are rarely poetic.

The shortest reliable mental model is this: the extension should feel close to the customer, but the backend should stay close to the truth.

Best internal links

Sources and further reading

FAQ

Should the extension call Rails for every piece of data?

No. Read Shopify-owned data directly from the Customer Account API or Storefront API when that keeps the flow simpler. Call Rails when you need app-owned records, external systems, durable business rules, or asynchronous work.

Can I trust the session token as proof that the whole request is safe?

No. Treat the token as proof that certain claims were signed by Shopify. Build authorization on top of verified claims plus server-side checks against your own shop installation, order state, and action rules.

When should I use a full-page customer account extension?

Use a full-page extension when the customer needs a multi-step workflow, richer navigation, or a dedicated destination page. Keep inline blocks for compact contextual UI and order action extensions for discrete order-specific actions.

Do customer account extensions work on legacy customer accounts?

No. They only work on Shopify’s new customer accounts, so your install, onboarding, and merchant support flows should detect that early.

Related resources

Keep exploring the playbook

Guides

Shopify session tokens in Rails

A Rails-focused guide to Shopify session tokens for embedded apps, covering the Shopify App gem, token exchange, frontend request flow, and the mistakes that still break production auth.

guidesShopify developersession tokens