Guides

Developer guide

Checkout UI Extensions with a custom backend architecture

A practical backend architecture guide for Shopify Checkout UI Extensions, covering when to call your own backend, how to keep the extension thin, and where teams overbuild the server side.

Updated March 12, 2026
15 min read
Editorial note: This guide assumes you are building a real app, not a demo where the backend politely exists only to say hello.

Why thin checkout extensions age better

Checkout is not a general frontend shell. It is a constrained runtime with explicit targets, explicit capabilities, and a security model that is much stricter than the average app page. Shopify's docs lean into that design: checkout extensions run in a Web Worker, capabilities such as api_access, network_access, and block_progress must be declared, and the platform exposes checkout-native APIs directly in the extension runtime.

That is why thin checkout extensions age better. They let checkout do checkout things, and they ask your backend only the questions your backend is uniquely qualified to answer. The extension becomes a small policy consumer, not a tiny SPA that developed a dependency problem and now calls home for emotional support every 400 milliseconds.

"UI extensions run in a Web Worker."

This matters architecturally. A checkout extension is not the right place to assemble a sprawling data graph from your server, compute six derived states, and then decide what the buyer is allowed to do. If the app needs durable enforcement, Shopify Functions are the stronger primitive. Shopify explicitly says validation functions run on Shopify's servers and can block checkout progress when business rules are not met.

"Validation functions run on Shopify's servers and can block checkout progress."

The working rule

The extension should render UI, collect narrow input, and ask your backend for app-owned decisions. It should not become a second checkout engine.

What this backend is actually for

The best checkout backends are boring in the best way. They answer a small number of important questions well. They do not act like a generic BFF layer for everything in checkout.

In practice, a custom backend behind a Checkout UI Extension should usually own four jobs.

  • Policy evaluation. Decide whether some app-owned condition is true, such as whether a buyer is eligible for a program, whether a merchant feature is enabled, or whether a checkout add-on should be offered.

  • Persistence. Save app-specific data that must live outside the current checkout session, such as a risk review record, compliance acknowledgment, offer audit trail, or deferred fulfillment preference.

  • Orchestration. Trigger the rest of your system, such as background jobs, external service calls, or internal domain workflows that should never be embedded into extension code.

  • Precomputed state serving. Return app-owned data that was prepared ahead of time and cannot be read directly from Shopify in the extension surface.

Notice what is missing from that list: "everything the UI might possibly want to know." That is how teams accidentally build a checkout-flavored micro-frontend platform, which is a phrase that should make any adult feel tired.

Examples that belong on your backend

  • Checking an app-owned entitlement table for premium checkout behavior.
  • Resolving merchant-specific rules stored in your own database.
  • Recording buyer input that must be audited later.
  • Calling a third-party compliance or risk provider.
  • Creating idempotent commands for downstream systems after a buyer action.

Examples that usually do not

  • Reading cart lines that the extension can already access.
  • Fetching product data that the Storefront API can already supply.
  • Reading configuration that could have been written to metafields ahead of checkout.
  • Returning every label, color, and branch decision from a remote endpoint.

What should not go through your backend

Shopify's configuration docs are surprisingly clear here. Before you request network access, Shopify tells you to consider alternatives. One of the biggest is metafields. If your app can write shop, product, or customer metafields ahead of checkout through the Admin API, then checkout can often read what it needs without a live round trip to your server.

"Instead of fetching data with an external network call, consider retrieving the data from a metafield."

Shopify also gives checkout extensions a first-party path to the Storefront API via api_access. The docs explicitly recommend it for product data, tags, collections, price conversions, and similar read use cases, with authentication handled by Shopify. That is a strong signal: if the data already lives on Shopify and the extension can access it safely, piping it through your backend is often extra latency for no business value.

NeedBest sourceWhyMain caution
Cart lines, totals, buyer journey contextCheckout extension APIsNative, immediate, no extra server hopRespect target and capability limits
Product tags, listings, collections, metaobjectsStorefront API via api_accessShopify handles auth for the extensionUse only the scopes and fields available to this surface
Stable config known before checkoutMetafieldsPrecomputed state beats live dependency chainsKeep write-time sync disciplined
App-owned policy or off-platform stateYour backendThat is your system's jobAuthenticate requests and keep payloads narrow
Hard enforcement of checkout rulesShopify FunctionsRuns on Shopify's side and can reliably blockDo not confuse UI messaging with enforcement

A useful test is this: if your backend went down for five minutes, what would the buyer lose? If the honest answer is "the extension can no longer decide whether a legal or operationally critical rule applies," then you probably put an enforcement concern in the wrong layer.

The request flow to standardize on

When you genuinely need your own backend, standardize on a narrow action-oriented request flow. Shopify exposes a session token API on checkout targets, and the target docs say to call sessionToken.get() whenever you need to make a request to your backend. Cached tokens are returned when possible, so the extension does not need a homemade token cache with three edge cases and an identity crisis.

"You should call this method every time you need to make a request to your backend."

The standard request shape looks like this.

  1. The extension reads Shopify-native context locally.
  2. The extension asks for a fresh session token.
  3. The extension calls one action-specific backend endpoint.
  4. The backend verifies the token and resolves app-owned policy.
  5. The backend returns a small deterministic payload.
  6. The extension renders a result or graceful fallback.

1. Declare only the capabilities you actually need

api_version = "2026-01"
 
[[extensions]]
type = "ui_extension"
name = "Checkout policy banner"
handle = "checkout-policy-banner"
 
[[extensions.targeting]]
target = "purchase.checkout.block.render"
module = "./Checkout.jsx"
 
[extensions.capabilities]
api_access = true
network_access = true

Shopify's docs say network_access must be requested in the Partner Dashboard to publish the extension, and the capability must also be set in shopify.extension.toml. Use it intentionally. Asking for it "just in case" is usually a sign that architecture decisions have not happened yet.

2. Fetch a session token at call time

import {reactExtension, Banner, useApi} from '@shopify/ui-extensions-react/checkout';
import {useEffect, useState} from 'react';
 
export default reactExtension('purchase.checkout.block.render', () => <Extension />);
 
function Extension() {
  const api = useApi();
  const [state, setState] = useState({loading: true, message: '', status: 'info'});
 
  useEffect(() => {
    let cancelled = false;
 
    async function load() {
      try {
        const token = await api.sessionToken.get();
 
        const response = await fetch('https://app.example.com/api/checkout/policy/resolve', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${token}`,
          },
          body: JSON.stringify({
            checkoutToken: api.checkoutToken?.current,
          }),
        });
 
        if (!response.ok) {
          throw new Error(`Policy request failed with ${response.status}`);
        }
 
        const payload = await response.json();
        if (!cancelled) {
          setState({
            loading: false,
            status: payload.status,
            message: payload.message,
          });
        }
      } catch (_error) {
        if (!cancelled) {
          setState({
            loading: false,
            status: 'warning',
            message: 'We could not load app-specific checkout info right now.',
          });
        }
      }
    }
 
    load();
    return () => {
      cancelled = true;
    };
  }, [api]);
 
  if (state.loading) {
    return <Banner status="info">Checking app policy…</Banner>;
  }
 
  return <Banner status={state.status}>{state.message}</Banner>;
}

Keep the request payload boring. Send only what your backend cannot derive from trusted claims or from Shopify APIs your server can call itself. A small payload is easier to validate, easier to reason about, and harder to accidentally turn into a security bug.

3. Make CORS boring too

Shopify's docs state that UI extensions run in a Web Worker and the exact origin may change without notice. Because of that, your server must support CORS for any origin and always return Access-Control-Allow-Origin: * for extension requests. If you send Authorization and JSON, you will also need a standard preflight-friendly response for methods and headers on your side.

"The exact origin they run on may change without notice."

A Rails backend shape that stays sane

In Rails, treat checkout extension endpoints as command or policy endpoints, not as a generic namespace that gradually turns into a second application. One endpoint should do one thing. It should verify the session token, hydrate the app context, resolve a single policy, and return a compact response.

A route and controller with one job

# config/routes.rb
namespace :api do
  namespace :checkout do
    post "policy/resolve", to: "policies#resolve"
  end
end
# app/controllers/api/checkout/policies_controller.rb
class Api::Checkout::PoliciesController < ActionController::API
  before_action :handle_preflight
  before_action :set_cors_headers
  before_action :authenticate_checkout_extension!
 
  def resolve
    result = Checkout::PolicyResolver.new(
      shop_domain: current_shop_domain,
      customer_gid: current_customer_gid,
      checkout_token: params[:checkoutToken]
    ).call
 
    render json: {
      status: result.banner_status,
      message: result.message,
      eligible: result.eligible,
      code: result.code
    }
  rescue Checkout::PolicyResolver::UnknownShop
    render json: {error: "Unknown shop"}, status: :unauthorized
  end
 
  private
 
  def handle_preflight
    return unless request.options?
 
    set_cors_headers
    head :no_content
  end
 
  def authenticate_checkout_extension!
    token = request.authorization.to_s.delete_prefix("Bearer ").presence
    raise ActionController::BadRequest, "Missing bearer token" if token.blank?
 
    payload, = JWT.decode(
      token,
      ShopifyApp.configuration.secret,
      true,
      algorithm: "HS256",
      verify_aud: true,
      aud: ShopifyApp.configuration.api_key
    )
 
    @session_payload = payload.deep_symbolize_keys
 
    raise ActionController::BadRequest, "Expired token" if Time.at(@session_payload[:exp]) <= Time.now
    raise ActionController::BadRequest, "Missing destination" if @session_payload[:dest].blank?
  rescue JWT::DecodeError => e
    render json: {error: "Invalid session token", detail: e.message}, status: :unauthorized
  end
 
  def current_shop_domain
    URI.parse(@session_payload.fetch(:dest)).host
  end
 
  def current_customer_gid
    @session_payload[:sub]
  end
 
  def set_cors_headers
    response.set_header("Access-Control-Allow-Origin", "*")
    response.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
    response.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
  end
end

The important part is not the exact JWT helper you use. The important part is the trust model. Trust token claims after verification. Do not trust random client parameters when those claims already tell you the shop or customer identity.

Shopify's config docs explicitly warn that a session token guarantees the integrity of its claims, but not that the HTTP request itself originated from Shopify. That means your endpoint design still matters. A verified token can justify trusting a subject claim. It does not justify exposing a dangerous endpoint like /get-discount-code that happily hands out value to anyone who can satisfy your minimal conditions.

"It does not guarantee the request itself originated from Shopify."

Prefer an explicit service boundary

# app/services/checkout/policy_resolver.rb
module Checkout
  class PolicyResolver
    Result = Struct.new(:eligible, :message, :banner_status, :code, keyword_init: true)
    UnknownShop = Class.new(StandardError)
 
    def initialize(shop_domain:, customer_gid:, checkout_token:)
      @shop_domain = shop_domain
      @customer_gid = customer_gid
      @checkout_token = checkout_token
    end
 
    def call
      shop = Shop.find_by!(shopify_domain: @shop_domain)
 
      return Result.new(
        eligible: false,
        banner_status: "info",
        code: "feature_disabled",
        message: "This checkout feature is not enabled for this merchant."
      ) unless shop.checkout_policy_enabled?
 
      return Result.new(
        eligible: false,
        banner_status: "warning",
        code: "customer_missing",
        message: "We could not verify buyer eligibility for this feature."
      ) if @customer_gid.blank?
 
      eligibility = Eligibility::CheckoutProgram.new(shop: shop, customer_gid: @customer_gid).call
 
      if eligibility.allowed?
        Result.new(
          eligible: true,
          banner_status: "success",
          code: "eligible",
          message: "You are eligible for this checkout option."
        )
      else
        Result.new(
          eligible: false,
          banner_status: "info",
          code: eligibility.reason,
          message: eligibility.message
        )
      end
    rescue ActiveRecord::RecordNotFound
      raise UnknownShop
    end
  end
end

This is the right kind of boring. The controller handles HTTP and authentication. The service resolves business logic. The extension receives a tiny payload. Nobody is trying to serialize half the company's ontology into checkout.

If you are on Remix

Shopify also provides a dedicated helper for authenticating checkout extension requests in the Remix package: authenticate.public.checkout. Even if you are a Rails team, that official helper is useful as a reference for the exact trust boundary Shopify expects on this surface.

How to structure side effects and retries

Checkout code should assume retries, reloads, duplicate clicks, and inconsistent timing. The extension runtime is not malicious, but the network is a goblin and should be treated accordingly.

That means your backend endpoints should be either read-only or idempotent. If a buyer action creates durable state, put that behind an idempotency key and make duplicate requests harmless.

# app/services/checkout/record_acknowledgement.rb
module Checkout
  class RecordAcknowledgement
    def initialize(shop:, customer_gid:, idempotency_key:, payload:)
      @shop = shop
      @customer_gid = customer_gid
      @idempotency_key = idempotency_key
      @payload = payload
    end
 
    def call
      CheckoutAcknowledgement.find_or_create_by!(
        shop: @shop,
        customer_gid: @customer_gid,
        idempotency_key: @idempotency_key
      ) do |record|
        record.payload = @payload
        record.recorded_at = Time.current
      end
    end
  end
end

Do not make the extension responsible for deduplication strategy. Let it send a stable intent key and let the backend own correctness.

Differentiate UI blocking from durable enforcement

Shopify supports checkout UI extension behavior like blocking progress when the extension has the block_progress capability and the merchant has allowed it. But Shopify also increasingly pushes developers toward Functions for reliable validation. In January 2026, Shopify's changelog explicitly recommended building custom checkout validation with Cart and Checkout Validation Functions rather than Checkout UI Extensions because they are more secure, more performant, and guaranteed to run across supported checkouts.

That is the architectural line to keep straight:

  • Extension UI: communicate, collect, guide, and sometimes request block behavior in the supported buyer journey APIs.

  • Your backend: resolve app-owned state and persist app-owned actions.

  • Functions: enforce rules that must hold even when your backend or UI is having a bad day.

If you blur these layers, you end up with the classic architecture smell: the buyer sees a stern banner that says something is forbidden, but the actual checkout rule is enforced by hope, timing, and a fetch call to a sleepy server in another region.

What teams overbuild

The common overbuilt version of this architecture usually has five symptoms.

  • A generic extension API. One endpoint becomes many concerns, then many concerns become a parallel backend surface that nobody can reason about safely.

  • Too many round trips. One request for config, one for eligibility, one for labels, one for a button state, one for the weather on Mars. Checkout is not your place to practice distributed systems fan fiction.

  • Server-rendered UI decisions. The extension asks the backend what text to show, what branch to take, and what the local state means, instead of treating the backend as a narrow policy provider.

  • Misplaced enforcement. A team relies on a backend response to "forbid" a checkout action that really needs Function-level enforcement.

  • App proxy habit transfer. Someone copies a storefront or theme pattern into checkout and discovers that app proxies in checkout have extra caveats, including CORS, no logged_in_customer_id, and lack of support on password-protected shops.

Shopify's docs spell out those app proxy caveats very directly. Checkout extension requests to an app proxy are CORS requests, they do not get logged_in_customer_id, and password-protected shops are not supported because the extension's Web Worker does not share the parent window session.

So yes, app proxies can still exist in your universe. No, they should not be your default mental model for Checkout UI Extensions.

The architecture decision table

When a team is unsure whether a checkout extension should call the backend, this is the rubric I would standardize on.

QuestionIf yesIf no
Does Shopify already expose the data in extension APIs?Read it locally in the extension.Continue down the table.
Can the Storefront API provide the data safely?Use api_access, not your backend.Continue down the table.
Can the data be written ahead of checkout as metafields?Precompute it and read it in checkout.Continue down the table.
Is the logic app-owned, off-platform, or dependent on your own DB?Call your backend through a narrow endpoint.Continue down the table.
Must the rule be durably enforced during checkout?Use Shopify Functions or another supported Shopify primitive for enforcement.The backend can remain advisory.

Most healthy architectures settle into this shape:

  • Checkout UI Extension for native context and buyer interaction.
  • Storefront API for Shopify-owned product or catalog reads.
  • Metafields for precomputed merchant or product state.
  • Custom backend for app-owned policy, persistence, and orchestration.
  • Shopify Functions for real enforcement.

That stack is easier to test, easier to explain to merchants, and much less likely to turn future you into a detective investigating why checkout depends on four endpoints, two cookies, and one ancient decision made by someone who has since become a gardener.

Best internal links

Sources and further reading

Source notes: checked against Shopify documentation and changelog on March 12, 2026. Checkout capabilities, network access requirements, enforcement guidance, and target behavior can evolve, so validate details against the current API version you ship.

FAQ

Should a Checkout UI Extension call my backend for most of its data?

Usually no. Pull checkout-native data from extension APIs, use Storefront API access when Shopify already exposes the data safely, and call your backend only for app-owned logic or persistence.

Can my backend enforce checkout rules by itself?

Not reliably. If the rule must block or guarantee checkout behavior, use Shopify Functions or supported checkout APIs. A backend can advise the extension, but durable enforcement belongs on Shopify's side.

Do I need session tokens for requests from Checkout UI Extensions?

Yes. Fetch a session token for each backend request, send it as bearer auth, and verify it server-side before trusting claims such as shop or customer identity.

Are app proxies the right default for checkout extensions?

No. They can work, but they come with checkout-specific caveats including CORS behavior, missing `logged_in_customer_id`, and unsupported behavior on password-protected shops.

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