Guides

Developer guide

React frontend plus Rails backend for Shopify embedded apps

How to structure a Shopify embedded app with React on the frontend and Rails on the backend, including auth boundaries, deployment shape, and the operational tradeoffs that matter in production.

Updated March 12, 2026
16 min read
Editorial note: This architecture is not about aesthetic purity. It is about not letting your React app slowly become a confused auth client, a half-backend, and an awkward home for every future Shopify surface.

Why this split works well for Shopify apps

Shopify embedded apps rarely stay small. The app home starts as a dashboard, then billing logic arrives, then webhooks, then background syncs, then admin UI extensions, then some merchant asks for a bulk action that turns your neat little frontend into an operational control panel with opinions.

React plus Rails works well because it matches the real platform split. Shopify says app home is the primary place where merchants engage with your app, and on the web it renders inside an iframe in Shopify admin. Shopify also says App Bridge is what lets your app communicate with the admin and create admin-owned UI like navigation menus, title bars, and save bars.

“On the web, the surface is an iframe”

“App Bridge components don't render as part of the app's component hierarchy.”

That last detail is more important than it looks. It means App Bridge is not your UI framework. It is your bridge to Shopify-admin-owned behavior. React still owns your actual component tree, client interaction state, and the merchant workflows inside the app surface. Rails owns the parts that must remain true when React is gone, reloaded, broken, or being “refactored” by someone with too much confidence.

Shopify also requires public apps to provide a consistent embedded experience and use the latest App Bridge. So this split is not just nice engineering hygiene. It aligns with the platform expectations your app is being judged against.

“Your app must provide a consistent embedded experience”

Optimize for year two, not week two

The best Shopify app architecture is the one that still makes sense after you add webhooks, bulk jobs, billing, admin extensions, and a merchant-specific “one tiny exception” that somehow becomes permanent.

The minimum request lifecycle to keep

Keep the request lifecycle narrow. The app loads inside Shopify admin. App Bridge handles the embedded context. React makes backend requests. Shopify session-token authentication protects those requests. Rails verifies them, resolves the shop and user context, and then decides what durable work needs to happen.

Shopify’s session-token docs say the embedded app first loads unauthenticated and serves the frontend shell. After that, the frontend includes a session token in the authorization header for backend requests. Shopify’s embedded-authorization docs also say that when App Bridge is set up properly, it appends the authorization header automatically.

“When your embedded app first loads, it's unauthenticated”

“it'll append a new header field authorization

In practice, the minimum healthy lifecycle looks like this:

  1. Shopify admin opens your embedded app home.
  2. React renders the shell and merchant UI.
  3. React calls Rails endpoints for authenticated app data.
  4. Rails verifies the request context and resolves shop plus user state.
  5. Rails either reads or writes your own database, calls the Admin API, or enqueues background work.

  6. React renders the result and stays blissfully ignorant about token exchange.

That is the boundary worth protecting. React initiates requests. Rails owns durable correctness. If your frontend starts deciding which Admin API token type to use, your architecture has already wandered into the forest.

export async function apiFetch<T>(input: RequestInfo, init: RequestInit = {}) {
  const response = await fetch(input, {
    ...init,
    headers: {
      Accept: "application/json",
      ...(init.headers || {}),
    },
  });
 
  if (!response.ok) {
    throw new Error(`Request failed with ${response.status}`);
  }
 
  return response.json() as Promise<T>;
}

The point of this example is not the fetch wrapper. It is the absence of auth drama. In a healthy embedded setup, your frontend request code should be boring.

What belongs in React versus Rails

The cleanest split is responsibility by failure mode. Ask a simple question: if the merchant closes the tab, which parts still need to remain correct?

ConcernReact frontendRails backendWhy
Navigation and screensYesNoThe embedded app surface is a UI problem
Forms, tables, filters, onboarding polishYesNoThese are merchant interaction concerns
Session verification and shop resolutionNoYesTrusted auth context belongs on the server
Admin API orchestrationNoYesRequires durable credentials and controlled retries
Persistence and audit trailsNoYesThe browser is not a reliable system of record
Webhook handling and jobsNoYesThey must run without an open browser
Optimistic UI and local stateYesNoFast UX lives in the client
Business rules that multiple surfaces needNoYesThey should survive app home, extensions, and jobs

Rails should own token verification, token exchange, Admin API clients, billing checks, persistence, job orchestration, and anything else that has to remain true when the UI is closed. React should own the merchant experience, including navigation, tables, forms, optimistic updates, and workflow polish.

Teams get into trouble when React knows too much about Shopify auth and Rails starts returning too much presentation logic. Then every new surface becomes a negotiation between two halves of the app that both think they are in charge.

Shopify’s own platform split reinforces this. App home is hosted by you inside the admin surface, while App Bridge is how you render admin-owned controls around that surface. That is a strong hint that your app should keep UI responsibilities and durable backend responsibilities clearly separated.

“App Bridge enables you to do the following from your app home”

The backend contract React should call

A good embedded frontend does not call “everything”. It calls a deliberate backend contract. That contract should be shaped around merchant actions and app workflows, not around random database tables or whatever your GraphQL client happened to return.

Good backend contracts usually look like this:

  • GET /api/embedded/dashboard for the app-home boot payload
  • POST /api/embedded/settings for merchant configuration writes
  • POST /api/embedded/syncs for async work requests
  • GET /api/embedded/billing for billing and plan state

Bad backend contracts usually look like this:

  • generic pass-through GraphQL endpoints
  • controllers that mirror frontend route names instead of business actions
  • endpoints that only make sense for app home and cannot be reused anywhere else
  • JSON payloads that dump raw internal models because it was “quicker”

A simple Rails shape:

# config/routes.rb
namespace :api do
  namespace :embedded do
    resource :dashboard, only: :show
    resource :settings, only: [:show, :update]
    resources :syncs, only: :create
  end
end
class Api::Embedded::DashboardController < ApplicationController
  def show
    render json: DashboardPayloadBuilder.call(
      shop: Current.shop,
      shopify_user_id: Current.shopify_user_id
    )
  end
end
class DashboardPayloadBuilder
  def self.call(shop:, shopify_user_id:)
    {
      shop: shop.shopify_domain,
      userId: shopify_user_id,
      plan: shop.current_plan_name,
      pendingSyncs: shop.sync_runs.pending.limit(5).map { |run| serialize_sync(run) },
      featureFlags: {
        bulkEdit: shop.bulk_edit_enabled?,
        autoSync: shop.auto_sync_enabled?
      }
    }
  end
 
  def self.serialize_sync(run)
    {
      id: run.id,
      status: run.status,
      startedAt: run.started_at
    }
  end
end

The important thing is not the JSON. It is that the frontend is calling an app-owned contract, not smuggling backend architecture decisions into the browser.

The backend API rule

React should call endpoints named after app actions and app state, not after your internal tables. Merchants clicked a button, not a row in sync_runs.

Deployment and routing choices

Two deployment shapes work well in practice.

ShapeWhen it fitsStrengthMain caution
Rails serves the built frontendSmall team, Shopify-specific app home, one release trainOperationally simpleFrontend deploys are coupled to backend deploys
Separate React and Rails deploysIndependent frontend cadence, multiple UI surfaces, larger teamClearer API boundaryMore deployment and environment coordination

If the app is mostly Shopify app home UI and a Rails backend, a single deploy is often the better trade. Fewer moving pieces, fewer cross-origin headaches, fewer “why is staging talking to production” moments. Separate deploys make more sense when the frontend genuinely has its own life.

Shopify’s docs reinforce two constraints regardless of which shape you pick. First, your app home still lives inside the Shopify admin surface. Second, public apps are expected to use the latest App Bridge and provide a consistent embedded experience within the admin.

“The primary place where users engage with your app is its app home”

“all apps must use the latest Shopify App Bridge”

One more practical point: if you are building a new public app, Shopify says new public apps must use the GraphQL Admin API, with REST now considered legacy for new public-app work. That makes a Rails backend even more useful as the place where Admin-API orchestration lives cleanly instead of leaking into the client.

“all new public apps must be built exclusively with the GraphQL Admin API”

Designing for extensions jobs and webhooks

This is where the React plus Rails split really earns its keep. Your app home is not the only thing that will want backend logic. Admin UI extensions, webhook processors, and background jobs all want the same business rules without sharing the same request shape.

Shopify documents authenticated requests from admin UI extensions to your app’s backend. It also says relative fetch() URLs in admin UI extensions resolve against your app URL when the backend is on the same domain, and the authorization header is added automatically. That is a strong reason to keep your business logic in reusable Rails services instead of burying it inside an app-home controller.

“an Authorization header is automatically added”

That gives you a healthy layering model:

  • controllers verify request context and shape input
  • service objects implement business logic
  • jobs run asynchronous or retryable work
  • webhook handlers translate Shopify events into backend actions
  • extensions call the same service layer through their own request path
class SyncProducts
  def self.call(shop:, actor_shopify_user_id: nil, source:)
    # business logic that app home, admin actions, and jobs can all reuse
    SyncRun.create!(
      shop: shop,
      actor_shopify_user_id: actor_shopify_user_id,
      source: source,
      status: "queued"
    )
 
    ProductSyncJob.perform_later(shop.id)
  end
end
class Api::Embedded::SyncsController < ApplicationController
  def create
    SyncProducts.call(
      shop: Current.shop,
      actor_shopify_user_id: Current.shopify_user_id,
      source: "app_home"
    )
 
    head :accepted
  end
end
class Webhooks::ProductsUpdateController < ApplicationController
  def create
    SyncProducts.call(
      shop: Current.shop,
      source: "webhook"
    )
 
    head :ok
  end
end

The winning move is not “make everything reusable” in the abstract. It is “make the business logic reusable while keeping the request authentication specific to the surface”. App home auth is not webhook auth. Admin extension auth is not app-home auth. The service layer should not care.

Failure modes that show up later

These are the failure modes that do not always show up in week one, but absolutely show up later:

  • Trusting shop context from the client. The frontend may know which shop is open. That does not make it the authority.

  • Letting React own Shopify authorization decisions. React should initiate requests, not decide token exchange strategy or long-lived credential use.

  • Building endpoints that only work for app home. If admin extensions, jobs, or webhooks cannot reuse the business logic, your backend is too UI-shaped.

  • Returning HTML-shaped responses from Rails for everything. That makes new surfaces harder, not easier.

  • Mixing online and offline token usage without explicit service boundaries. Then debugging permissions becomes a group activity.

  • Letting frontend route structure leak into install or auth behavior. Shopify auth boundaries should not care what your tab layout looks like this week.

  • Over-separating deployments too early. Two deploys can be good. Two deploys plus vague ownership plus duplicated environment config is how tiny apps create unnecessary operational complexity.

Shopify’s starter paths already push you toward a sensible embedded architecture. Shopify CLI scaffolds starter apps with embedded auth boilerplate, and the current shopify_app defaults use session tokens for embedded admin auth and OAuth 2.0 token exchange for API access. So when teams end up with an embedded frontend that is also moonlighting as a backend, it is usually self-inflicted.

“starter app with boilerplate code that handles authentication and authorization”

“By default, this app is configured to use session tokens”

The common theme in all of these mistakes is boundary drift. Once the boundary drifts, auth gets weird, extensions get awkward, jobs get special cases, and your React app slowly develops backend opinions it absolutely should not have.

The smell test

If closing the browser would break the correctness of the operation, the operation belongs in Rails.

Best internal links

Sources and further reading

FAQ

Should React call Shopify directly for most app logic?

Usually no. React should drive the merchant experience and call your backend. Rails should own durable state, Admin API orchestration, and anything that must stay correct when the browser is closed.

Should I deploy React separately from Rails?

Sometimes, but not by default. If the app is mostly Shopify app home UI, a single deploy is often simpler. Separate deploys are more useful when the frontend truly has its own release cadence or multiple non-Rails surfaces.

Does App Bridge replace my frontend router or state model?

No. App Bridge handles communication with Shopify admin and admin-owned UI elements like nav, title bars, and save bars. Your app still owns its own UI tree and client-side state.

Can admin UI extensions reuse the same Rails backend?

Yes, and they should. Shopify documents authenticated requests from admin UI extensions to your app backend, so a clean Rails service layer can support both your embedded app home and your extensions.

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