ADSX
JULY 21, 2026

Shopify Remix App Template: Complete Guide (2026)

Anatomy of the Shopify Remix app template: shopify.app.toml, session token auth, Prisma session storage, webhooks, and fixes for the common gotchas.

AUTHOR
AT
AdsX Team
E-COMMERCE SPECIALISTS
READ TIME
11 MIN
SUMMARY

Anatomy of the Shopify Remix app template: shopify.app.toml, session token auth, Prisma session storage, webhooks, and fixes for the common gotchas.

The Shopify Remix app template is the official starting point for embedded apps in 2026. It ships session token auth with token exchange, Prisma session storage, GraphQL Admin API clients, webhook plumbing, and an extensions pipeline. This guide walks the anatomy file by file, then covers the gotchas that cost new app developers the most time.

Code editor showing the file structure of a Shopify Remix app
CODE EDITOR SHOWING THE FILE STRUCTURE OF A SHOPIFY REMIX APP

What the template decides for you

Run shopify app init, pick Remix, and Shopify has made about a dozen architectural decisions on your behalf: embedded by default, session tokens instead of cookie auth, token exchange instead of redirect OAuth, Prisma for persistence, Vite for the build, and the GraphQL Admin API for everything (REST product endpoints are legacy; GraphQL vs REST covers why that fight is over).

Accepting these defaults is usually right. Every shopify.dev example, every CLI workflow, and most community answers assume this exact shape. If you want the wider context of app types and the Partner program first, start with the Shopify API and app development guide, then come back here for the internals.

To follow along you need a Partner account and a development store to install against; both are free. A live Shopify store is worth having too once your app touches checkout or real order flows, since dev stores only simulate payments.

The file structure that matters

Fresh scaffold, trimmed to the files you will actually touch:

restock-radar/
├── shopify.app.toml          # app config: URLs, scopes, webhooks
├── prisma/
│   └── schema.prisma         # Session model (+ your own tables)
├── app/
│   ├── shopify.server.js     # shopifyApp() setup: the heart of the app
│   ├── db.server.js          # Prisma client singleton
│   ├── entry.server.jsx      # Remix server entry (CSP headers live here)
│   └── routes/
│       ├── app.jsx           # authenticated layout: App Bridge + nav
│       ├── app._index.jsx    # your app's home page
│       ├── auth.$.jsx        # auth plumbing (rarely edited)
│       └── webhooks.app.uninstalled.jsx
├── extensions/               # UI extensions and Functions
└── package.json
FileWhat it doesHow often you touch it
shopify.app.tomlDeclares scopes, webhooks, URLs to ShopifyOften
app/shopify.server.jsConfigures auth, session storage, billingOccasionally
app/routes/app.*.jsxYour actual pagesConstantly
app/routes/webhooks.*.jsxWebhook handlersPer topic
prisma/schema.prismaSession model plus your dataAs features grow
extensions/Admin blocks, checkout UI, FunctionsPer extension

The naming convention doing quiet work here: Remix flat routes. app.inventory.jsx becomes /app/inventory and renders inside the app.jsx layout, which is what wires up App Bridge and authenticated context. Name a route outside the app. prefix and it renders without any of that, which is correct for webhooks and public pages and wrong for admin UI.

shopify.app.toml: your app's contract with Shopify

This file is the source of truth for how Shopify sees your app. The CLI reads it, shopify app deploy pushes it, and version control tracks it.

client_id = "a1b2c3d4e5f6"
name = "restock-radar"
application_url = "https://restock-radar.fly.dev"
embedded = true

[access_scopes]
scopes = "read_products,write_products"

[auth]
redirect_urls = ["https://restock-radar.fly.dev/auth/callback"]

[webhooks]
api_version = "2026-04"

[[webhooks.subscriptions]]
topics = ["app/uninstalled"]
uri = "/webhooks/app/uninstalled"

[[webhooks.subscriptions]]
topics = ["products/update"]
uri = "/webhooks/products/update"

Three things worth internalizing. Scopes are config, not code: change them here and deploy, and merchants approve on next load. Webhook subscriptions are declarative: no imperative registration calls scattered through your codebase, and the subscription ships atomically with each app version. And api_version pins which quarterly Admin API release your webhooks use; keep it matched to the version your queries target.

Multiple environments are handled with multiple toml files (shopify.app.staging.toml) and shopify app config use staging. Do this early; pointing one app registration at both staging and production URLs ends badly.

Auth: session tokens and token exchange

The template's auth model confuses people who built Shopify apps before 2024, because the OAuth redirect dance is mostly gone. The current flow:

  1. A merchant opens your app inside the Shopify admin iframe.
  2. App Bridge (a script tag from Shopify's CDN) fetches a session token: a signed JWT identifying the shop and user, valid for about a minute.
  3. Your frontend sends that token with each request to your Remix server.
  4. On the server, authenticate.admin(request) verifies the JWT, exchanges it for an API access token for that shop (token exchange), stores the result in session storage, and hands you a ready-to-use Admin API client.

All of that is one line in practice:

export const loader = async ({ request }) => {
  const { admin, session } = await authenticate.admin(request);
  // admin.graphql(...) is authenticated for session.shop
};

Installation is Shopify-managed: the admin reads your required scopes from the toml and shows the grant screen itself, with no /auth/begin redirect round-trip for the standard case. The auth.$.jsx route still exists to catch edge flows; you will almost never edit it.

The one rule to tattoo somewhere: every route that renders admin UI or touches merchant data starts with authenticate.admin(request). It either resolves with a valid context or throws the correct redirect/401 response for you. Hand-rolling that logic is how apps end up with auth bypasses.

Session storage with Prisma

Token exchange produces access tokens, and they have to live somewhere. The template stores them via Prisma:

model Session {
  id            String    @id
  shop          String
  state         String
  isOnline      Boolean   @default(false)
  scope         String?
  expires       DateTime?
  accessToken   String
  userId        BigInt?
}

Wired up in shopify.server.js:

import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";

const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET,
  appUrl: process.env.SHOPIFY_APP_URL,
  sessionStorage: new PrismaSessionStorage(prisma),
  // ...
});

Development uses SQLite and that is fine. Production on Fly.io, Render, or anything with ephemeral disks needs Postgres (or another adapter); otherwise each deploy wipes dev.sqlite and every merchant session with it. The switch is the Prisma datasource block plus prisma migrate deploy in your release step. Add your own models to the same schema; a Shop table keyed on domain, holding your app's per-store settings, shows up in nearly every real app.

Adding a page that queries the Admin API

The bread-and-butter pattern. New file, app/routes/app.inventory.jsx:

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticate } from "../shopify.server";

export const loader = async ({ request }) => {
  const { admin } = await authenticate.admin(request);

  const response = await admin.graphql(
    `#graphql
    query LowStock {
      productVariants(first: 25, query: "inventory_quantity:<5") {
        nodes {
          id
          sku
          inventoryQuantity
          product { title }
        }
      }
    }`
  );

  const { data } = await response.json();
  return json({ variants: data.productVariants.nodes });
};

export default function Inventory() {
  const { variants } = useLoaderData();
  return (
    <s-page heading="Low stock">
      <s-section heading="Variants under 5 units">
        {variants.map((v) => (
          <s-text key={v.id}>
            {v.product.title} ({v.sku ?? "no SKU"}): {v.inventoryQuantity}
          </s-text>
        ))}
      </s-section>
    </s-page>
  );
}

Add a link in the nav inside app.jsx and you have a page. Two notes. Recent scaffolds render app home with Polaris web components (the s- elements above); older ones use Polaris React imports. The loader pattern is identical either way, so match whatever your scaffold shipped. Second, the query cost model: GraphQL bills by complexity, and a loader that over-fetches will eventually throttle. Patterns for staying under the limit are in Catalog API rate limits and errors, and the Admin API guide covers the cost calculation itself.

Webhooks: registration and handling

Registration you have already seen: [[webhooks.subscriptions]] entries in the toml. Handlers are Remix routes named to match the URI:

// app/routes/webhooks.products.update.jsx
import { authenticate } from "../shopify.server";

export const action = async ({ request }) => {
  const { shop, topic, payload } = await authenticate.webhook(request);

  // fast work only; queue the rest
  await recordProductChange(shop, payload);

  return new Response();
};

authenticate.webhook verifies the HMAC signature against the raw request body. That "raw" matters: any middleware that parses or rewrites the body before verification breaks the HMAC and every delivery 401s. The template's setup avoids this; custom server tweaks are how it usually breaks.

Respond fast with a 2xx. Shopify retries failed deliveries and will eventually remove subscriptions from endpoints that keep failing. High-volume topics like products/update arrive in bursts (a bulk edit can produce thousands of events), so idempotency and debouncing are not optional at scale; catalog change webhooks goes deep on those patterns.

Test locally with shopify app webhook trigger --topic products/update --delivery-method http --address http://localhost:3000/webhooks/products/update.

The extensions folder

Anything under extensions/ deploys to Shopify's infrastructure (not your server) when you run shopify app deploy: admin blocks and actions, checkout UI extensions (the replacement for checkout.liquid, which no longer exists as an option), theme app extensions, and Shopify Functions for discount, shipping, and payment logic now that Scripts have sunset. Generate each with shopify app generate extension; the CLI writes the TOML targeting config so the extension appears in the right admin or checkout surface.

The mental model: your Remix server is the stateful brain, extensions are stateless UI and logic that Shopify hosts and injects. Extensions talk back to your server over authenticated fetch when they need data you own.

Environment variables and production wiring

The template reads its identity from the environment, and the split between toml and env trips people up: the toml declares what Shopify should know (scopes, URLs, webhook topics), while env vars hold what your server needs at runtime.

The ones that matter:

  • SHOPIFY_API_KEY and SHOPIFY_API_SECRET: the app's client credentials from the Partner Dashboard.
  • SHOPIFY_APP_URL: where the server believes it is running; must agree with application_url.
  • DATABASE_URL: the Prisma datasource.
  • SCOPES: optional when scopes are managed in the toml, which they should be.

In development you rarely set any of these by hand, because shopify app dev injects them from the linked app. For production hosts, the CLI will read them back out for you:

shopify app env show   # print the app's keys and URLs
shopify app env pull   # write them into .env for local tooling

Copy the values into your host's secret manager rather than committing an .env file. When credentials look right but auth still fails, compare the client_id in the toml against SHOPIFY_API_KEY in the running environment first; a mismatch between the two produces convincingly weird symptoms.

The gotchas list

Things that reliably burn a day each, and the fix:

Embedded auth loop. Symptoms: app loads, flashes, redirects, repeats. Causes in order of likelihood: application_url mismatch (stale tunnel URL from a previous shopify app dev session; restart or run shopify app dev --reset), opening the app URL directly instead of through the admin, or mismatched API key between .env and the toml.

CORS errors calling the Admin API from the browser. Not a config problem, a design problem. The Admin API does not accept browser calls from apps. Move the query into a loader or action; if client-side fetch is unavoidable, fetch your own Remix route and let the server proxy.

Scope change, no effect. Editing [access_scopes] does nothing until you shopify app deploy (or restart shopify app dev locally), and API calls still fail until the merchant re-approves on next app load. Build a graceful path for the in-between state.

Sessions vanish after deploy. SQLite on an ephemeral filesystem. Move to Postgres, run prisma migrate deploy on release.

Webhook 401s in production. Body parsing before HMAC verification, or the webhook secret from a different app instance (staging secret in prod env). Compare client_id first.

Admin refuses to embed the app after adding security headers. The template's entry.server.jsx sets a Content-Security-Policy with frame-ancestors scoped to the shop and Shopify admin. A reverse proxy or hosting platform that appends its own frame-blocking headers (X-Frame-Options: DENY is the classic) overrides that and the iframe goes blank. Strip the conflicting header at the proxy for app routes.

Next step

Scaffold the template, then make one deliberate modification of each kind before writing real features: add a page with a loader query, add a webhook subscription and handler, and change a scope and watch the re-approval flow. Those three edits touch every subsystem above, and after them the template stops feeling like a black box.

ABOUT THE AUTHOR
AT
AdsX Team
AI SEARCH SPECIALISTS

The AdsX team helps brands navigate AI-powered search and get recommended by ChatGPT, Claude, Perplexity, and other AI platforms. With deep expertise in LLM optimization, paid media, and e-commerce growth, our team has driven a 340% average increase in AI mentions for clients across industries.

MORE BY ADSX TEAM

Ready to Dominate AI Search?

Get your free AI visibility audit and see how your brand appears across ChatGPT, Claude, and more.

Get Your Free Audit