Skip to content

Commerce · Open source

shopify-app-tanstack-start

Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.

TanStack Start (React) v0.1.2 Free · MIT
shopify-app-tanstack-start: Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.

What it solves

Shopify publishes app packages for React Router, Remix and Express, but not for TanStack Start.

A Start app has to copy session token validation, token exchange, webhook HMAC checks, app proxy signatures and billing redirects out of boilerplate repos, and then deal with the fact that Shopify's code throws Response objects the React Router way, which Start's server functions cannot send as they are.

shopifyApp() takes the same options as shopifyApp() in @shopify/shopify-app-react-router 4.0.0 and returns the same server API (authenticate.admin, authenticate.webhook, authenticate.public.appProxy, billing, unauthenticated.admin and the rest), because its server core is that package's MIT-licensed code with React Router removed. On top it adds the Start layer: middleware bodies for page requests, server functions, webhooks and app proxy routes, a handler for the /auth/$ route, a ready handler for the compliance webhooks, and conversion of the Responses Shopify's code throws into router redirects, App Bridge 401 responses or a readable ShopifyResponseError. A separate React entry loads App Bridge in the document head and routes App Bridge navigation through TanStack Router. Apps are embedded only, and installation is Shopify-managed with token exchange; there is no OAuth authorization code flow. Tested with @tanstack/react-start 1.169 against a simulated Shopify, in dev and a production build, with 408 tests and a fresh npm install. It has not been tested on a live Shopify store yet; see Compatibility. A community package, not made or endorsed by Shopify or by TanStack.

  • shopifyApp() with the server API of @shopify/shopify-app-react-router 4.0.0, adapted from Shopify's MIT-licensed code: authenticate.admin, webhook, flow, fulfillmentService, pos, public.appProxy, public.checkout and public.customerAccount, unauthenticated.admin and unauthenticated.storefront, registerWebhooks, login and addDocumentResponseHeaders
  • Embedded admin auth: session tokens from the id_token URL parameter on page loads and the Authorization: Bearer header on server function calls, the bounce and exit-iframe pages, token exchange for offline (and with useOnlineTokens, online) access tokens, the afterAuth hook, and the 401 retry and reauthorize headers App Bridge acts on
  • Webhooks verified by HMAC-SHA256 over the raw request body: 401 for an invalid HMAC, 400 for other invalid requests and for a signed body that is not JSON, 405 for anything but POST; the context includes an Admin API client when the shop has a stored offline session
  • complianceWebhooks() handles customers/data_request, customers/redact and shop/redact, plus app/uninstalled if pointed at the same route, and deletes the shop's stored sessions on shop/redact and app/uninstalled; when a handler or the session deletion fails it calls onError and answers 500, so Shopify retries, and still answers 500 if onError itself throws
  • App proxy requests checked by signature and timestamp (by @shopify/shopify-api, within 90 seconds), with a session, Admin and Storefront clients when the shop has a stored session, and liquid() responses
  • Billing: require, check, request, cancel, createUsageRecord and updateUsageCappedAmount; scopes: query, request and revoke
  • Start middleware bodies (middleware.document, middleware.admin, middleware.webhook, middleware.appProxy) that you wrap in one createMiddleware() line each, and authRouteHandler() for the /auth/$ route
  • Responses thrown by billing, redirect() and authentication become router redirects in server functions, App Bridge 401 responses for browser calls, or a ShopifyResponseError while a page server-renders
  • React entry: appBridgeHead() for the root head() (App Bridge and, unless turned off, Polaris web components from Shopify's CDN) and AppProvider, which sends App Bridge navigation through TanStack Router
  • Session storage: any @shopify/shopify-app-session-storage adapter; only SQLite and memory have been tested
  • ESM only, marked side-effect free; Node 22.12 or later; peers @tanstack/react-start ^1.168, @tanstack/react-router ^1.170 and react >=18

Install

Requires TanStack Start (React).

npm install @amjadiqbal/shopify-app-tanstack-start

Usage

// src/shopify.server.ts
import { ApiVersion, shopifyApp } from '@amjadiqbal/shopify-app-tanstack-start/server';

export const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY!,
  apiSecretKey: process.env.SHOPIFY_API_SECRET!,
  appUrl: process.env.SHOPIFY_APP_URL!,
  scopes: process.env.SCOPES?.split(','),
  apiVersion: ApiVersion.July26,
  sessionStorage: new PrismaSessionStorage(prisma),
});

// src/shopify.middleware.ts
export const shopifyAdmin = createMiddleware({ type: 'function' }).server(async (ctx) =>
  ctx.next({ context: await shopify.middleware.admin(ctx) }),
);

// A server function in an embedded admin page
export const loadProducts = createServerFn()
  .middleware([shopifyAdmin])
  .handler(async ({ context: { admin } }) => {
    const response = await admin.graphql(`{ products(first: 5) { nodes { id title } } }`);
    return (await response.json()).data.products.nodes;
  });

Setup

Install the package in a TanStack Start app with React, plus a Shopify session storage adapter such as @shopify/shopify-app-session-storage-prisma, -drizzle, -sqlite or -redis (or -memory for development). Peer dependencies are @tanstack/react-start ^1.168, @tanstack/react-router ^1.170 and react >=18; Node 22.12 or later. The package brings @shopify/shopify-api 15 and Shopify's API clients as its own dependencies. In shopify.app.toml set embedded = true and use_legacy_install_flow = false, because the package only supports embedded apps with Shopify-managed installation and token exchange.

  1. 1 npm install @amjadiqbal/shopify-app-tanstack-start @shopify/shopify-app-session-storage-prisma
  2. 2 Create src/shopify.server.ts with shopifyApp({ apiKey, apiSecretKey, appUrl, scopes, apiVersion, sessionStorage }).
  3. 3 Create src/shopify.middleware.ts with one createMiddleware() wrapper per middleware body: document (global), admin (server functions), webhook and appProxy (server routes).
  4. 4 Register the document middleware in src/start.ts next to Start's CSRF middleware, which Start stops adding once you list your own request middleware: createStart(() => ({ requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), shopifyDocument] })).
  5. 5 Add src/routes/auth/$.ts with a GET handler that returns shopify.authRouteHandler(request).
  6. 6 In src/routes/__root.tsx, spread appBridgeHead({ apiKey }) into head() and render <AppProvider> around the app. Expose the client ID to the browser as VITE_SHOPIFY_API_KEY.

Middleware and the auth route

The package exports middleware bodies rather than finished middleware: each takes the object Start passes to .server() and returns the context for next(). You wrap each one in a single createMiddleware() line in your own app.

  • middleware.document: for a global request middleware. Every GET or HEAD request that is not a server function call is treated as an embedded admin page. It sets Shopify's document headers and runs authenticate.admin(). With a valid shop parameter the page gets Content-Security-Policy: frame-ancestors (the shop and the Shopify admin) and a Link header that preloads App Bridge and Polaris; without one, neither. Merchant custom apps (AppDistribution.ShopifyAdmin) always get frame-ancestors 'none'. Responses it throws (bounce redirect, redirect into the admin, App Bridge page) go to the browser as they are.
  • exclude takes path prefixes or RegExps for public pages and GET server routes that are not admin pages. The auth path prefix (/auth by default) is always excluded, as are requests that carry both signature and path_prefix parameters (app proxy requests). Excluded requests get no headers and no authentication. The middleware only checks that the proxy parameters are present; the signature is checked by middleware.appProxy, so serve app proxy paths with it. A page reached with made-up proxy parameters still gets no admin data, because middleware.admin then authenticates the server function itself.
  • middleware.admin: for server function middleware. It reuses the document middleware's result while a page server-renders, otherwise it runs authenticate.admin() on the request. It returns admin, session, billing, redirect, cors, scopes and sessionToken.
  • middleware.webhook returns { webhook }; middleware.appProxy returns { appProxy }.
  • authRouteHandler(request) serves the session token bounce page (/auth/session-token) and the exit-iframe page (/auth/exit-iframe), which refuses destinations outside the app and the Shopify admin. Anything else under the auth path that authenticates gets a 204.
export const shopifyDocument = createMiddleware().server(async (ctx) =>
  ctx.next({ context: await shopify.middleware.document(ctx, { exclude: ['/api', '/public'] }) }),
);

export const Route = createFileRoute('/auth/$')({
  server: { handlers: { GET: ({ request }) => shopify.authRouteHandler(request) } },
});

How Shopify's Responses reach the browser

Shopify's server code throws Response objects for redirects, billing confirmation and failed authentication, as React Router loaders expect. Start cannot send those from a server function, so the admin middleware wraps the context's billing, scopes, admin and redirect helpers and converts what they throw.

  • A redirect Response becomes a TanStack Router redirect, whether the server function runs during server rendering or is called from the browser. billing.request() during server rendering goes through /auth/exit-iframe, which opens the confirmation page at the top level.
  • Called from the browser (the request has an Authorization header), any other Response goes back unchanged, so App Bridge can act on a 401 with X-Shopify-Retry-Invalid-Session-Request or X-Shopify-API-Request-Failure-Reauthorize-Url.
  • During server rendering, any other Response becomes a ShopifyResponseError carrying the response, its status and the page path, for your error boundary.
  • Requests from bots identified by isbot get a 410, as in Shopify's package.

Webhooks and compliance topics

shopify.authenticate.webhook(request), and middleware.webhook built on it, read the raw body and verify its HMAC-SHA256 against the HMAC header (X-Shopify-Hmac-Sha256 for standard webhooks) with your API secret, using @shopify/shopify-api. A non-POST request gets 405, an invalid HMAC 401, and a missing HMAC header, missing body or other invalid request 400, as does a correctly signed body that is not JSON. The context has topic, shop, apiVersion, webhookId and the parsed payload, plus session and an Admin API client when the shop has a stored offline session.

  • complianceWebhooks(handlers) returns a POST handler for the mandatory compliance topics. Handler names: customersDataRequest, customersRedact, shopRedact, appUninstalled and other. Each receives the full webhook context.
  • On shop/redact and app/uninstalled it also deletes every stored session for the shop, after your handler runs.
  • It answers 200 once your handler (and any session deletion) has finished. Topics without a handler are acknowledged and ignored. Requests that fail verification get the 405, 401 or 400 above.
  • If your handler or the session deletion throws, it passes the error and the webhook context to onError (console.error by default) and answers 500 so Shopify retries. When your handler is the one that failed, the sessions are left in place. If onError itself throws, that error is logged with console.error and the answer is still 500.
  • Shopify retries a webhook that takes more than 5 seconds, so queue slow work instead of doing it in the handler.
  • registerWebhooks({ session }) registers the shop-specific webhooks declared in shopifyApp({ webhooks }); app-specific subscriptions in shopify.app.toml are usually enough.
export const Route = createFileRoute('/webhooks/compliance')({
  server: {
    handlers: {
      POST: ({ request }) =>
        shopify.complianceWebhooks({
          customersDataRequest: async ({ shop, payload }) => queue.add('export-customer', { shop, payload }),
          customersRedact: async ({ shop, payload }) => queue.add('redact-customer', { shop, payload }),
          shopRedact: async ({ shop }) => db.shop.delete({ where: { domain: shop } }),
          onError: (error, { topic, shop }) => logger.error({ error, topic, shop }),
        })(request),
    },
  },
});

App proxy

shopify.authenticate.public.appProxy(request), and middleware.appProxy, check the signature query parameter with your API secret through @shopify/shopify-api, which also rejects a timestamp more than 90 seconds off the server's clock in either direction, and repeated signature, shop or timestamp parameters. An invalid signature gets a 400. The context has liquid(body, init) for application/liquid responses (pass { layout: false } to render without the theme layout), and session, admin and storefront clients when the shop has a stored offline session; without one they are undefined.

export const Route = createFileRoute('/proxy/$')({
  server: {
    middleware: [shopifyAppProxy],
    handlers: {
      GET: ({ context: { appProxy } }) => appProxy.liquid('<p>Hello {{ shop.name }}</p>'),
    },
  },
});

Billing

Declare plans in shopifyApp({ billing: { Pro: { lineItems: [...] } } }), as in the React Router package. The admin context's billing object has require, check, request, cancel, createUsageRecord and updateUsageCappedAmount. A confirmation redirect from request() opens at the top level of the admin: through the exit-iframe page during server rendering, or through App Bridge's reauthorize header when the server function was called from the browser.

const requirePro = createServerFn()
  .middleware([shopifyAdmin])
  .handler(async ({ context: { billing } }) => {
    await billing.require({
      plans: ['Pro'],
      onFailure: () => billing.request({ plan: 'Pro' }),
    });
  });

The rest of the server API

These come from Shopify's code and are covered by its ported unit tests, but were not exercised in the Start test app. The one change: Flow and fulfillment service requests whose signed body is not JSON get a 400 instead of an uncaught JSON.parse error.

  • authenticate.flow, authenticate.fulfillmentService and authenticate.pos.
  • authenticate.public.checkout and authenticate.public.customerAccount, for extension requests.
  • unauthenticated.admin(shop) and unauthenticated.storefront(shop), for background jobs; they throw SessionNotFoundError when the shop has no stored offline session.
  • login(request), which sends a shop to Shopify's managed install page (/oauth/install), for apps distributed through the App Store or as single-merchant apps.
  • Merchant custom apps with distribution: AppDistribution.ShopifyAdmin, which build the session from the configured admin API access token instead of token exchange; no session storage is required for them.
  • Expiring offline access tokens behind future.expiringOfflineAccessTokens, refreshed when close to expiry.

App Bridge and navigation

Import from @amjadiqbal/shopify-app-tanstack-start/react. appBridgeHead({ apiKey, polaris, polarisUrl }) returns { meta, scripts } for the root route's head(): the shopify-api-key meta tag, App Bridge from cdn.shopify.com, and Polaris web components unless polaris is false. AppProvider listens for App Bridge's shopify:navigate event and calls router.navigate() with the link's href, so <s-app-nav> links change routes without reloading the iframe. It takes only children; unlike Shopify's React AppProvider it has no apiKey or embedded props, because appBridgeHead() does that part.

Migrating from @shopify/shopify-app-react-router

shopifyApp() takes the same options, except isEmbeddedApp, which it rejects with an error because apps are always embedded. The authenticate, unauthenticated and billing APIs are the same; what changes is where you call them.

  • authenticate.admin(request) in a loader or action becomes .middleware([shopifyAdmin]) on a server function, then context.admin, context.billing and so on.
  • The app/routes/auth.$.tsx loader becomes src/routes/auth/$.ts with shopify.authRouteHandler(request).
  • addDocumentResponseHeaders in entry.server.tsx is done by middleware.document.
  • boundary.error and boundary.headers are not needed; Responses are converted as described above.
  • <AppProvider embedded apiKey> becomes appBridgeHead() in the root head() plus <AppProvider>.
  • authenticate.webhook(request) moves to a server route POST handler, or the webhook middleware.
  • Not provided: the React Router error boundary, AppProxyProvider and AppProxyLink, the /test-helpers entry, non-embedded apps, and the OAuth authorization code install flow.

Compatibility and testing

Tested with @tanstack/react-start 1.169, @tanstack/react-router 1.170, React 19 and Node 26 against a simulated Shopify: session tokens signed with the app secret, webhook and app proxy HMACs computed as Shopify computes them, and a fake Admin API (token exchange and GraphQL) on the server's fetch, with a small stand-in for App Bridge. A real Start app installed the packed tarball with SQLite session storage and was checked in dev and in a production build, and the package was also installed fresh from npm. It has not been tested on a live Shopify store: no development store, no Shopify CLI (shopify app dev), no real App Bridge inside the admin iframe, no real Admin GraphQL responses, and no session adapters other than SQLite and memory. If something behaves differently on a live store, please open an issue.

  • 408 unit tests at v0.1.2, all passing: 367 are Shopify's own tests for the React Router package's server code, ported to Vitest and run against this package's core; 41 are new tests for the Start layer, including type tests that check the middleware context reaches createServerFn().handler() and server route handlers with the real @tanstack/react-start types.
  • The tests added in 0.1.2 cover a correctly signed body that is not JSON (400 from the webhook middleware and from complianceWebhooks), onError itself throwing (still 500) and a failed session deletion (500 through onError). The 400 for Flow and fulfillment service bodies has no test of its own.
  • Typecheck also passes against the oldest supported peers, Start 1.168.0 and Router 1.170.0.
  • Seen working in the test app: embedded page load with token exchange and a stored session, afterAuth run once, server functions reusing the page's authentication during server rendering, the bounce and into-admin redirects, the exit-iframe page and its refusal of a foreign destination, browser server-function calls with an expired token answered 401 and retried, <s-app-nav> navigation without reloads, billing from a page load and from the browser, compliance webhooks (200, and 401 on a bad HMAC), app proxy liquid() (and 400 on a bad signature), bot rejection (410), and the frame-ancestors and preload headers.
  • CSRF, as reported in the README: in the test app, with the setup above, a cross-site POST to a server function carrying a valid session token got 403 with createCsrfMiddleware registered and 200 without it; same-origin calls got 200.
  • The React entry (appBridgeHead and AppProvider) has no unit tests; it was checked only in the test app with the App Bridge stand-in.
  • Flow, fulfillment service, POS, checkout and customer account authentication, unauthenticated contexts and merchant custom apps are covered by the ported unit tests only.

Credits

The server core (src/core and src/shared) is adapted from Shopify's @shopify/shopify-app-react-router 4.0.0, MIT licence, copyright Shopify Inc., with React Router removed: its redirect() replaced by a plain Response, the error boundary and Node adapter dropped, webhook, Flow and fulfillment service bodies that are not JSON answered with 400, and the library name, user agent and version changed. Shopify's licence is included in LICENSE.md, and src/core/README.md lists the changes. The Start layer, the compliance webhook handler and the React entry are new. It runs on Shopify's @shopify/shopify-api. Shopify is a trademark of Shopify Inc. This is a community package, not made or endorsed by Shopify or by TanStack.

Hire me

Need this built into your app, or something custom?

I wrote shopify-app-tanstack-start. I can wire it into your TanStack app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.

Support

Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.

Need something built?

Free quote in 60 seconds