Apps Overview and Scaffolding

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Shopify CLI 5.0.0 (published 2026-10-27, requires Node.js 22.12 or later), the Admin API version 2026-10, the React Router app template (repository Shopify/shopify-app-template-react-router, commit 48b841a of 2026-10-09, which pins React Router 7.18.2 and depends on @shopify/shopify-app-react-router at ^3.0.1, so a fresh install resolves 3.0.2; version 4.0.0 of that package was published on 2026-10-21), @shopify/app-bridge-react 4.2.14 and Polaris web components (the default CDN channel serves 1.1; 2.0 is stable since 2026-10-22). Everything was checked on 2026-10-27. Apps are available on every Shopify plan; the places where a capability needs Shopify Plus (for example a custom app that contains a Shopify Function, or one custom app installed on several stores of a Plus organization) are named where they appear.

An app is the unit through which code reaches a shop: it is installed by a merchant, holds a set of access scopes, calls the Admin GraphQL API, receives webhooks, and can ship extensions that Shopify itself hosts and runs. This page is the entry point of the app part of the Shopify subsection. It explains how apps are distributed, how to scaffold one with shopify app init, what the React Router template contains file by file, how to move a Remix app to it, every key of shopify.app.toml, and how an app project is laid out on disk. Authentication, Polaris and App Bridge, webhooks, the Admin API, Functions and deployment each have their own page (see Authentication and Access Scopes, App UI: Polaris and App Bridge, Webhooks and Events, Shopify Functions and Billing, App Store and Deployment); here they appear only as far as the scaffolding needs them. Tooling setup, dev stores and the AI tooling are in Development Environment.

The running example is Pocket Coffee, a small specialty-coffee shop. Its developer builds one app, Pocket Coffee Tools, in the pages that follow: an order logger driven by a webhook, a Coffee Club discount implemented as a Function, and a roast-date badge. This page creates the project, the configuration and the webhook route.

What a Shopify app is made of

A Shopify app built with Shopify CLI has four parts. The app record lives on Shopify (in the Dev Dashboard): it holds the client ID and client secret, the icon and contact details merchants see, the distribution method and the released app versions. The configuration is the shopify.app.toml file in the project; shopify app deploy copies it into an app version. The optional web app is a server you host yourself, which renders the app’s page inside the Shopify admin and receives webhooks. The extensions are small units of code or configuration (a checkout block, a discount Function, a theme block) that are built by the CLI, uploaded with the app version and executed by Shopify, so they need no server of yours.

The anatomy of a Shopify app: the merchant’s admin, the developer-hosted app server, the Shopify platform, the project on disk and the extensions that Shopify runs

Two consequences follow. Releasing an app version never deploys the web app: shopify app deploy publishes configuration and extensions only, and the web app goes to a host of your choice. And a lightweight app can have no web app at all: an extension-only app is made of extensions only and needs no hosting.

Choosing the app type

The way an app reaches merchants is its distribution method, chosen once per app. Shopify states that it cannot be changed after it is selected, and recommends choosing it on the production app when development and production are separate apps. The older words "public app" and "custom app" still describe the two methods.

Distribution methods

Method Where it installs Authentication Shopify approval Limits

Public distribution

Any number of stores

Token exchange when embedded; authorization code grant when not embedded

Yes, App Store review

Must sync certain data with Shopify under the API terms; may bill through Shopify

Custom distribution

One store, several stores of the same Plus organization, or development stores

Token exchange when embedded; authorization code grant when not embedded

No

Cannot charge merchants through Shopify’s billing system

Admin-created custom app (no longer available for new apps)

One store

A pre-generated Admin API access token

No

Cannot be created any more; cannot use App Bridge or app extensions; cannot use Shopify billing

Public distribution is for software sold to many merchants through the Shopify App Store. It requires the App Store requirements and review, and it is the only method that Shopify’s own billing can charge for. Custom distribution is for software written for one merchant or one Plus organization: Shopify generates an install link and the developer sends it to the store owner. A custom app can be installed on several stores only when they belong to the same Plus organization, and that option can be switched off when the link is generated (the setting is called Allow multi-store installs for one Plus organization). Apps whose App Home is a Shopify-hosted UI extension can use custom distribution only.

Two older app types can no longer be created: private apps (deprecated January 2022, automatically converted to custom apps on 2023-01-20) and unpublished apps (deprecated 2019-12-09). The admin-created custom app, built under Settings, Apps, Develop apps inside the Shopify admin, is a third legacy form: existing ones keep working, new ones cannot be created, and Shopify directs new work to the Dev Dashboard or to Shopify CLI.

A decision path for Pocket Coffee Tools

Pocket Coffee is one merchant, so the order logger and the Coffee Club discount only ever install on its own store: custom distribution fits. If the same developer later sells the roast-date badge to other roasters, that part becomes a separate public app. The flowchart condenses the choice.

flowchart TD A["New app idea"] --> B{"Who installs it?"} B -->|"One merchant or one Plus organization"| C["Custom distribution"] B -->|"Many unrelated merchants"| D["Public distribution"] B -->|"Only stores of my own organization, no merchant UI"| E["Dev Dashboard app with the client credentials grant"] C --> F{"Needs a server-side backend?"} F -->|"No"| G["Extension-only app with an App Home UI extension"] F -->|"Yes"| H["React Router template, single-merchant distribution"] D --> I["React Router template, App Store distribution"] I --> J["Partner Dashboard listing and review"]

Where the app is created: Shopify CLI or Dev Dashboard

Every app of an organization appears in the Dev Dashboard (dev.shopify.com/dashboard), whichever tool created it. The Dev Dashboard was relaunched on 2026-09-25 as the home for apps, stores, collaborator access and app health; the Partner Dashboard is still used for App Store submission and quality checks. There are two ways to create an app.

  • Shopify CLI (shopify app init) is the recommended path for feature-rich apps. The configuration is code (shopify.app.toml), extensions and Functions are available, and the web app and extensions are developed and deployed together.

  • The Dev Dashboard ("Start from Dev Dashboard") suits simple integrations: connecting an existing system, automation, or API-only work with no UI in the admin. The developer fills in a version (app URL, webhook API version, scopes) and releases it. Extensions and Functions need the CLI, because only the CLI can build an app version that includes them.

An app created in the Dev Dashboard can be moved under CLI management later: scaffold an extension-only project with shopify app init, run shopify app config link, choose to connect it to an existing app, and check the imported shopify.app.toml. From then on a change is made in the file and released with shopify app deploy. The reasons Shopify gives are version control of the configuration, access to extensions and Functions, settings that only the CLI manages (handles), and managing staging and production apps from one project.

An app that acts only on stores of the developer’s own organization needs no merchant install: it authenticates with the client credentials grant using its client ID and secret and receives a token valid for 24 hours. This is the usual choice for an integration between Pocket Coffee’s own back office and its store.

Other languages and stacks

React Router (Node.js) is Shopify’s recommended stack and the only app template shopify app init offers apart from the extension-only one. Shopify’s official libraries cover other stacks. On 2026-08-26 Shopify released official PHP (shopify/shopify-app-php on Packagist) and Python (shopifyapp on PyPI) packages at version 1.0, which provide request verification, token exchange (including client credentials and refresh) and an Admin GraphQL client with retries; the older shopify-api-php and shopify_python_api libraries are deprecated. There is no PHP or Python app template, so those projects scaffold their own code, and the CLI remains available for configuration and extensions.

Prerequisites and installing Shopify CLI

Shopify CLI 5.0.0 needs Node.js 22.12 or later (the engines.node field of the package says >=22.12.0), a package manager (npm, Yarn 1.x or pnpm) and Git; the documentation says Git 2.28.0 or later, and the 5.0.0 release notes add that Git operations require Git 2.38.0 or later. It also needs a current Chrome or Firefox for the login and preview flows. Install it globally, which Shopify recommends because it gives one install and upgrade point for apps, themes and Hydrogen:

npm install -g @shopify/cli@latest
shopify version

Yarn global and pnpm work the same way, and Homebrew on macOS offers brew tap shopify/shopify && brew install shopify-cli. The CLI can instead be a local dependency of the app (npm install -D @shopify/cli, then npm run shopify <command>), which is useful to pin one CLI version for a whole team. Since version 3.59.0 the separate @shopify/app package is bundled into @shopify/cli and must not be installed. The React Router template removed its local CLI dependency in August 2025 in favour of the global install, but its package.json still defines scripts such as dev and deploy that call shopify app commands.

CLI 4.0.0 (2026-05-21) introduced semantic versioning and automatic upgrades. Auto-upgrade is skipped in CI, for project-local installs and for major releases, and shopify config autoupgrade off disables it. Usage data (commands, flags, timings, errors and identifiers such as the app API key and the store domain) is collected by default; set SHOPIFY_CLI_NO_ANALYTICS=1 or OPT_OUT_INSTRUMENTATION=true to opt out. CLI 4.0.0 also removed --force from app deploy and app release, app generate extension --type (use --template), app generate schema (use app function schema) and app webhook trigger --shared-secret (use --client-secret); scripts written for 3.x break on these.

A developer account is needed for everything that talks to Shopify, and so is a development store to install the app on (shopify store create dev, or Create store in the Dev Dashboard). Development stores are always password protected, cannot be transferred, are limited to 250 per organization, and take test payments with the Test payment gateway or a provider’s test mode. The commands that need that identity are listed at the end of this page together with what was and was not executed.

Creating the project with shopify app init

shopify app init creates a new directory with a working app. Run interactively it logs in, asks for the template, the organization and the app name, creates the app record in the Dev Dashboard, writes the project, links it with shopify.app.toml and installs dependencies:

cd ~/projects
shopify app init

The prompts, in order, are: log in to the developer account (a Partner account or a merchant account with the right permissions); choose Build a React Router app (the other choice is Build an extension-only app); choose the organization that owns the dev store; confirm Yes, create it as a new app; and enter a name. The project is created in a new subdirectory. To pick the template without the prompt, pass --template.

Flags

Flag Meaning

-n, --name

Name of the new app; skips the app selection prompt. Required in non-interactive runs unless --client-id is given.

-p, --path

Directory in which to create the project (default: the current directory).

--template

reactRouter, none, or a GitHub repository with optional branch and subpath, for example https://github.com/Shopify/<repository>/[subpath]#[branch]. Required when non-interactive.

--flavor

javascript or typescript. Required in non-interactive runs when the template offers several flavors (the CLI rejects any other value: "The --flavor flag for reactRouter accepts only javascript, typescript").

-d, --package-manager

npm, yarn, pnpm or bun.

--organization-id

Organization ID, found in the Dev Dashboard URL https://dev.shopify.com/dashboard/<organization-id>;.

--client-id

Client ID of an existing app: links the new project to that app instead of creating one.

--no-input

Disables prompts and browser authentication.

-j, --json

Prints the result as JSON (the AppInitResult schema: status, app id, name, client ID, organization ID, directory, configuration path, package manager, template).

--json-schema, --auth-alias, --no-color, --verbose

Print the schemas, choose which logged-in account to use, disable color, print more output.

Both forms from the CLI help follow. The first creates a new app non-interactively; the second links a new project to an existing app and uses the empty template.

shopify app init --name pocket-coffee-tools --organization-id <your-organization-id> --template reactRouter --flavor typescript

shopify app init --client-id <your-client-id> --template none

The same command accepts a template URL, which is how the React Router template is referenced in Shopify’s own package documentation:

shopify app init --template=https://github.com/Shopify/shopify-app-template-react-router

Templates

Value What is created

reactRouter

A full-stack embedded app: a React Router 7 server with App Bridge and Polaris web components, session storage in Prisma (SQLite by default), an extensions/ directory that starts empty, and the shopify.app.toml and shopify.web.toml files described below. Recommended for most apps and the only choice for public apps that need server logic.

none

An extension-only app, with no web app of its own: configuration plus an extensions/ directory. The extension-only template includes an App Home UI extension by default, and application_url defaults to https://shopify.dev/apps/default-app-home. Extension-only apps can be installed with custom distribution only.

GitHub URL

Any repository laid out as a Shopify app project, for example the React Router template itself.

An extension-only app can also get a developer-hosted App Home page: set application_url to the hosted URL. That is the route to public distribution, because the Shopify-hosted App Home UI extension is restricted to custom distribution.

The old Remix template is no longer offered: the --template option of CLI 5.0.0 names only reactRouter and none (plus URLs). Apps created from the Remix template keep working, and the migration is described later on this page.

What the CLI does after the project exists

The scaffolded project is connected to the Dev Dashboard app it created. The first shopify app dev or shopify app config link updates shopify.app.toml to reflect the linked app (the template ships it with an empty client_id). The generated project also handles authentication: the template performs the token exchange so the developer never writes an OAuth flow. Once the project exists, cd into it and run shopify app dev (see the development loop below), press p to open the preview URL, and click Install app on the dev store. The home page of the template then offers a Generate a product button that runs a productCreate mutation.

What was not executed

The login that app init needs creates a developer session, and no account or login is used while writing this page, so shopify app init was run only as far as its refusal. In a non-interactive shell it stops with: "To run this command, log in to Shopify. Authorization is required to continue, but the current environment does not support interactive prompts." and creates nothing. It also reports missing flags (--name or --client-id, --organization-id or --client-id, and --flavor) before authenticating when they are absent. All the project-level steps (typecheck, production build, lint, running the built server with placeholder values, validating configuration) were executed on a copy of the public template, and are reported where they appear below.

The React Router template

The template is the open-source repository Shopify/shopify-app-template-react-router. Shopify states that it is forked from the Remix template and converted to React Router, and that the template is the recommended path. Rather than cloning the repository, scaffold with shopify app init so the project is linked to an app record.

Directory layout

pocket-coffee-tools/
  shopify.app.toml                    app configuration
  shopify.web.toml                    how the CLI builds and runs the web process
  package.json                        scripts, dependencies, workspaces (extensions/*)
  vite.config.ts                      Vite and React Router plugin, dev server and HMR settings
  tsconfig.json    env.d.ts           TypeScript configuration
  .graphqlrc.ts                       GraphQL codegen for the Admin API and for each extension
  .eslintrc.cjs    .prettierignore    lint configuration
  .npmrc                              engine-strict and shamefully-hoist
  Dockerfile                          container build for production
  prisma/
    schema.prisma                     Session model (SQLite by default)
    migrations/
  app/
    root.tsx                          HTML shell
    routes.ts                         flat-file routing
    entry.server.tsx                  streaming server render and Shopify response headers
    shopify.server.ts                 the shopifyApp() configuration and its exports
    db.server.ts                      Prisma client
    routes/
      _index/route.tsx                public landing page and shop login form
      auth.$.tsx                      authentication callback routes (splat)
      auth.login/route.tsx            login form for non-embedded entry
      app.tsx                         layout route of the embedded app
      app._index.tsx                  home page of the app
      app.additional.tsx              second page that shows navigation
      webhooks.app.uninstalled.tsx    handler for the app/uninstalled topic
      webhooks.app.scopes_update.tsx  handler for the app/scopes_update topic
  extensions/                         empty; extensions are generated here
  public/

The repository also carries development-assistant files (AGENTS.md, CLAUDE.md, .mcp.json for the Shopify Dev MCP, a Gemini extension and Cursor settings) and a CHANGELOG.md. The template’s changelog is the quickest way to see what changed between scaffolds: 2026.10.09 moved the API version to 2026-10; 2026.09.03 pinned React Router to 7.18.2 because 7.18.3 tightened origin validation of actions and made every action return 400 Bad Request under shopify app dev and behind TLS-terminating proxies, and forced qs to ^6.16.0 to clear two advisories; 2025.12.11 added the refresh-token fields and the expiringOfflineAccessTokens flag.

shopify.server.ts: the central configuration

Every other file imports from app/shopify.server.ts. It calls shopifyApp() once and re-exports the pieces.

import "@shopify/shopify-app-react-router/adapters/node";
import {
  ApiVersion,
  AppDistribution,
  shopifyApp,
} from "@shopify/shopify-app-react-router/server";
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 || "",
  apiVersion: ApiVersion.October26,
  scopes: process.env.SCOPES?.split(","),
  appUrl: process.env.SHOPIFY_APP_URL || "",
  authPathPrefix: "/auth",
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    expiringOfflineAccessTokens: true,
  },
  ...(process.env.SHOP_CUSTOM_DOMAIN
    ? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
    : {}),
});

export default shopify;
export const apiVersion = ApiVersion.October26;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;

The first import selects the Node adapter, which supplies the Web fetch and crypto implementations the package expects. The options and what they do:

Option Meaning

apiKey, apiSecretKey

The app’s client ID and client secret. shopify app dev injects them as SHOPIFY_API_KEY and SHOPIFY_API_SECRET; in production they come from the host’s secret store. The secret never reaches the browser.

apiVersion

The Admin API version used by admin.graphql and for webhook validation. The template uses ApiVersion.October26, that is 2026-10. Quarterly versions are supported for at least 12 months.

scopes

Taken from the SCOPES environment variable that the CLI sets from shopify.app.toml. With Shopify managed installation, the file is the source of truth.

appUrl

The public URL of the app. During development it is the tunnel URL in SHOPIFY_APP_URL; in production it is the production URL.

authPathPrefix

The path Shopify reserves for authentication endpoints (default /auth). It must match a splat route (auth.$.tsx) whose loader calls authenticate.admin(request).

sessionStorage

Where sessions, and so access tokens, are stored. Optional for apps created in the Shopify admin. The template uses the Prisma adapter.

distribution

AppDistribution.AppStore (the default, for public apps), SingleMerchant (for custom apps managed in the Dev Dashboard or Partner Dashboard) or ShopifyAdmin (for apps created in the merchant’s admin). It changes the shape of the returned object: a ShopifyAdmin app has no login function and no embedded redirect helper.

future.expiringOfflineAccessTokens

Turns on 1-hour offline tokens with refresh tokens, refreshed automatically. Required for all public apps from 2027-01-01 and for new public apps since 2026-04-01.

customShopDomains

Extra domains that count as shop domains, read here from SHOP_CUSTOM_DOMAIN.

useOnlineTokens

Not set in the template. When true, online (per-staff-member) tokens are used in addition to offline ones.

hooks.afterAuth

Not set in the template. Runs after a merchant installs the app and when a token expires.

webhooks

Not set in the template. Shop-specific webhook subscriptions registered in code; app-specific subscriptions in the TOML file are recommended.

billing

Not set in the template. Line-item billing plans for the Billing API.

polarisUrl

URL of the Polaris web components script for the preload header; must equal the value passed to AppProvider. Defaults to the Shopify CDN. Used to opt in to Polaris 2.0 (needs package version 2.1.0 or later).

logger

Log level and handler.

What the object offers: authenticate.admin (for requests from the app’s UI or from admin extensions), authenticate.webhook, authenticate.public, authenticate.flow, authenticate.fulfillmentService and authenticate.pos; unauthenticated.admin and unauthenticated.storefront for background work that has an offline session; login for the shop-domain form; registerWebhooks; addDocumentResponseHeaders; and sessionStorage. authenticate.admin returns the session, an admin client whose only method is graphql, a billing helper, a cors helper, the decoded sessionToken, a redirect helper and scopes (with query, request and revoke). The Admin client has no REST method: the package removed REST, as the migration section explains.

Routes, layout and the shell

app/routes.ts is one line, export default flatRoutes();, so file names map to URLs: app._index.tsx is /app, app.additional.tsx is /app/additional, webhooks.app.uninstalled.tsx is /webhooks/app/uninstalled. app.tsx is the layout route of every page below /app:

import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { Outlet, useLoaderData, useRouteError } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { AppProvider } from "@shopify/shopify-app-react-router/react";

import { authenticate } from "../shopify.server";

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

  return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};

export default function App() {
  const { apiKey } = useLoaderData<typeof loader>();

  return (
    <AppProvider apiKey={apiKey}>
      <s-app-nav>
        <s-link href="/app">Home</s-link>
        <s-link href="/app/additional">Additional page</s-link>
      </s-app-nav>
      <Outlet />
    </AppProvider>
  );
}

// Shopify needs React Router to catch some thrown responses, so that their headers are included in the response.
export function ErrorBoundary() {
  return boundary.error(useRouteError());
}

export const headers: HeadersFunction = (headersArgs) => {
  return boundary.headers(headersArgs);
};

The layout does four jobs. Its loader calls authenticate.admin(request), which validates the ID token, performs the token exchange and, when there is no session, redirects through the right authentication flow. AppProvider loads App Bridge and the Polaris web components script and needs the client ID, which the loader passes down because the browser cannot read process.env. s-app-nav declares the navigation menu in the admin sidebar, built from s-link elements (the older documentation shows NavMenu from App Bridge React with Link children; both exist). The ErrorBoundary and headers exports are required: Shopify needs React Router to catch some thrown responses so their headers survive, and the boundary helpers do that. Every route that calls authenticate.admin must export headers the same way.

The other routes:

File Purpose

root.tsx

The HTML document: charset, viewport, preconnect to cdn.shopify.com, the Inter font stylesheet, Meta, Links, Outlet, ScrollRestoration, Scripts.

entry.server.tsx

Streams the render with renderToPipeableStream, calls addDocumentResponseHeaders(request, responseHeaders) so every HTML response gets the required Content-Security-Policy frame-ancestors directive and preload Link headers, waits for the full shell for bots, and aborts after 6 seconds (streamTimeout is 5000 ms).

_index/route.tsx

The public landing page. If the URL has a shop parameter it redirects to /app with the same query string; otherwise it shows a heading, a feature list and a shop-domain form that posts to /auth/login. Replace the placeholder text.

auth.login/route.tsx

Renders the login form with Polaris components and passes the submission to login(request); error.server.tsx turns MissingShop and InvalidShop into messages.

auth.$.tsx

A splat route whose loader calls authenticate.admin(request); it serves the paths below authPathPrefix that the package uses for authentication.

app._index.tsx

The home page. Its action runs a productCreate mutation and then productVariantsBulkUpdate through admin.graphql, and an Edit product button calls the App Bridge intents API with edit:shopify/Product.

app.additional.tsx

A second page that shows how a page appears in the app navigation.

webhooks.app.uninstalled.tsx

Authenticates the webhook and deletes the shop’s sessions.

webhooks.app.scopes_update.tsx

Authenticates the webhook and stores the new scope list in the session row.

Webhook routes must not be nested under the app layout route, because Shopify calls them without an admin session. That is why their file names start with webhooks. and not with app..

A first custom route: the order logger

Pocket Coffee wants the line "order placed" with the order number and the total in its log. That needs a webhook subscription in the configuration and a route to receive it. The subscription asks for only the three fields the handler reads:

[[webhooks.subscriptions]]
topics = ["orders/create"]
uri = "/webhooks/orders/create"
include_fields = ["id", "name", "total_price_set"]

and the route, app/routes/webhooks.orders.create.tsx:

import type { ActionFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";

interface OrderCreatePayload {
  id: number;
  name: string;
  total_price_set: { shop_money: { amount: string; currency_code: string } };
}

export const action = async ({ request }: ActionFunctionArgs) => {
  const { topic, shop, payload } = await authenticate.webhook(request);
  const order = payload as unknown as OrderCreatePayload;
  const total = order.total_price_set.shop_money;

  // Pocket Coffee: log "order placed" with the order number and the total.
  console.log(`order placed ${order.name} total ${total.amount} ${total.currency_code} (${topic} from ${shop})`);

  return new Response();
};

The orders/create topic needs the read_orders scope, so the scope list in shopify.app.toml must contain it (the configuration example below does). The route was added to a copy of the template and run: npm run typecheck passed, the production build passed, and the built server was started with placeholder values (SHOPIFY_API_KEY of 32 zeros, a made-up secret, SHOPIFY_APP_URL=https://pocket-coffee-app.example.test). A hand-signed request, with a synthetic payload and the made-up secret, returned 200 and logged order placed #1001 total 38.80 EUR (ORDERS_CREATE from pocket-coffee-dev.myshopify.com); the same signature on a changed body returned 401. The topic arrives in the enum form ORDERS_CREATE, not orders/create. No request left the machine and no real order or store was involved.

Session storage and Prisma

prisma/schema.prisma defines one model, Session, with the columns id, shop, state, isOnline, scope, expires, accessToken, userId, firstName, lastName, email, accountOwner, locale, collaborator, emailVerified, and, for expiring offline tokens, refreshToken and refreshTokenExpires. The datasource is file:dev.sqlite. SQLite is fine for a single running instance, which is the case for Pocket Coffee’s one store, but a multi-instance deployment needs another Prisma datasource or a different session-storage adapter (MySQL, PostgreSQL, Redis and MongoDB adapters exist). If a route fails with "The table main.Session does not exist", run the setup script, which is prisma generate && prisma migrate deploy. The .env files, prisma/dev.sqlite and the .shopify directory are git-ignored by the template; the lockfiles are ignored too, which conflicts with Shopify’s recommendation to commit the lock file of the package manager, so teams usually remove those lines.

Scripts, Vite and the web process

Script Runs

dev

shopify app dev

build

react-router build

start

react-router-serve ./build/server/index.js

setup

prisma generate && prisma migrate deploy

docker-start

npm run setup && npm run start

deploy, config:link, config:use, env, generate, shopify

Shortcuts to the matching shopify app commands

lint, typecheck

ESLint, and react-router typegen && tsc --noEmit

graphql-codegen, prisma, vite

The tools themselves

vite.config.ts contains two non-obvious pieces. It replaces the HOST variable the CLI sets with SHOPIFY_APP_URL, because a HOST value breaks the Vite server (the comment says the CLI will stop passing it in a later major release); and it picks the hot-module-reload settings from the host name, WebSocket on port 64999 for localhost and secure WebSocket on FRONTEND_PORT (default 8002) through port 443 for a tunnel. The server listens on PORT (default 3000).

.graphqlrc.ts configures GraphQL code generation for the Admin API version 2026-10 over the files below app/ and adds one project per extension that has a schema.graphql. .npmrc sets engine-strict=true, which makes an old Node.js fail the install, and shamefully-hoist=true, which recent npm versions report as an unknown project setting; the warning is harmless. The Dockerfile starts from node:22-alpine, installs OpenSSL, runs npm ci --omit=dev, builds the app and starts it with npm run docker-start. The template’s own workflow runs typecheck, ESLint, prisma validate and the build on Node 22 and 24 with npm, Yarn and pnpm.

Checks that were run on the template

Step on a copy of the template Result

npm install (scripts ignored), npx prisma generate

Installs React Router 7.18.2, @shopify/shopify-app-react-router 3.0.2, @shopify/app-bridge-react 4.2.14, Prisma 6.19.3, @shopify/shopify-api 15.0.1.

npm run typecheck

Passes with zero errors. Without prisma generate first it reports that @prisma/client has no exported member PrismaClient.

npx react-router build

Builds the client and server bundles with Vite 7.3.7; prints future-flag warnings about React Router 8 (middleware, route-module splitting, Vite Environment API, request pass-through, trailing-slash-aware data requests).

npm run lint

Passes.

npx prisma migrate deploy

Applies the single migration create_session_table.

Built server with placeholder values: GET /

200 and the landing page with the shop form.

GET /auth/login

200 and the Polaris login form.

POST /auth/login with shop=pocket-coffee-dev.myshopify.com

302 to https://admin.shopify.com/store/pocket-coffee-dev/oauth/install?client_id=<client-id>;, the Shopify-managed install page. Only the redirect is built; nothing is fetched.

GET /app?shop=pocket-coffee-dev.myshopify.com&host=abc

410 Gone for a request with no session token, with a Content-Security-Policy frame-ancestors for the shop, admin.shopify.com and a few internal hosts, Link preload headers for App Bridge and Polaris, and a minimal "Handling response" page. Inside a real admin the request carries an ID token; that path was not run.

POST /webhooks/app/uninstalled, no Shopify headers

400.

The same, with Shopify headers and a wrong HMAC

401.

The webhook status codes follow Shopify’s rule that an invalid HMAC must receive 401 Unauthorized; a request without the Shopify headers is rejected earlier with 400.

Migrating from Remix to React Router

Shopify’s first app template was built on Remix. React Router 7 is the continuation of Remix, and Shopify forked the Remix package into @shopify/shopify-app-react-router. The Remix package is still published (6.0.2, 2026-10-08), but the CLI no longer offers the Remix template, the documentation tells readers that the React Router package is the recommended path, and the upgrade guide says support for the Remix package is limited to critical security issues. A new project should not start on Remix, and an existing one should plan the move, because the new template is where UI, authentication and token changes land first (Polaris web components, expiring offline tokens).

What the React Router package drops or changes

Area Change

REST

The REST Admin client was removed from the package; authenticate.admin returns a GraphQL client only. Migrate the calls to GraphQL (the REST Admin API has been legacy since 2024-10-01 and new public apps must use GraphQL only), or use the REST client from @shopify/admin-api-client directly.

Non-embedded apps

Not supported in the React Router package (about 2 percent of Remix apps). Move the app to embedded or stay on the Remix package.

Authorization code flow

Only Shopify managed installation with token exchange exists. Before migrating an embedded Remix app, enable the unstable_newEmbeddedAuthStrategy future flag there. Apps created after 2024-01-25 already use it.

AppProxyForm

Removed; use a plain lowercase form.

Polaris

Polaris React is replaced by Polaris web components (s-page, s-section, s-button, and so on). Polaris React is deprecated and no longer maintained. A gradual path using both libraries exists but ships both and mixes design versions.

Package name and imports

@shopify/shopify-app-remix becomes @shopify/shopify-app-react-router (/server, /react, /adapters/node).

AppProvider props

isEmbeddedApp becomes embedded; the new AppProvider takes apiKey and optional polarisUrl and loads App Bridge and Polaris itself.

Loaders and actions

React Router 7 single fetch: return plain objects, not json(); every route that calls authenticate.admin must export headers that calls boundary.headers.

The migration steps

Shopify publishes two example migration branches (the template itself, and the QR code example app) and a step list. In order:

  1. Adopt Vite if the app predates 2024-02-21 (the Vite plugin for Remix).

  2. Adopt the Remix future flags one by one: remove installGlobals from vite.config.ts and change engines.node in package.json; add @remix-run/node and vite/client to types in tsconfig.json; turn on v3_singleFetch; remove json() from loaders and actions; optionally replace the ESLint and TypeScript configurations with the recommended ones. With single fetch, response headers are handled differently, so each route that calls authenticate.admin must add:

    import { boundary } from "@shopify/shopify-app-remix/server";
    import type { HeadersFunction } from "@remix-run/node";
    
    export const headers: HeadersFunction = (headersArgs) => {
      return boundary.headers(headersArgs);
    };
  3. Upgrade to React Router with the codemod, then check app/routes.ts and simplify the Vite plugin:

    npx codemod remix/2/react-router/upgrade && npm install
    import { flatRoutes } from "@react-router/fs-routes";
    
    export default flatRoutes();

    In vite.config.ts, replace the reactRouter({ ignoredRouteFiles, future: {...} }) call with reactRouter().

  4. Tell the CLI how to run it in shopify.web.toml: the name becomes React Router and the dev command becomes npx prisma migrate deploy && npm exec react-router dev.

  5. Enable route type generation: ignore .react-router/ in .gitignore, point env.d.ts to @react-router/node, and add .react-router/types/**/* to include and ./.react-router/types to rootDirs in tsconfig.json.

  6. Swap the Shopify package: replace the dependency, then the imports in app/shopify.server.ts, every route that imports boundary and app/routes/auth.login/error.server.tsx.

  7. Switch to App Bridge and Polaris web components: delete the Polaris React stylesheet link and PolarisAppProvider from app/routes/app.tsx and the login route, rename isEmbeddedApp to embedded, and import AppProvider from @shopify/shopify-app-react-router/react (use embedded={false} on the login page). Add the @shopify/polaris-types package (called @shopify/app-bridge-ui-types in older guides) to tsconfig.json types so the s-* elements type-check, and add variant to the ignore list of react/no-unknown-property in ESLint.

  8. Port the UI from Polaris React to web components, route by route. The template and the QR code example each have a commit that shows a full conversion of the login route.

The login route is the smallest worked case:

// before (Polaris React)
<Page>
  <Card>
    <Form method="post">
      <FormLayout>
        <Text variant="headingMd" as="h2">Log in</Text>
        <TextField type="text" name="shop" label="Shop domain" value={shop} onChange={setShop} error={errors.shop} />
        <Button submit>Log in</Button>
      </FormLayout>
    </Form>
  </Card>
</Page>

// after (Polaris web components)
<s-page>
  <Form method="post">
    <s-section heading="Log in">
      <s-text-field name="shop" label="Shop domain" value={shop} error={errors.shop}></s-text-field>
      <s-button type="submit">Log in</s-button>
    </s-section>
  </Form>
</s-page>

After the swap, three template changes are worth adopting even though the guide predates them: set future.expiringOfflineAccessTokens and add the two refresh-token columns to the Prisma Session model (required for public apps from 2027-01-01), bump apiVersion, and move @shopify/api-codegen-preset and .graphqlrc.ts to the React Router package. The migration was not executed here, because it needs an existing Remix app and the codemod; the steps above are Shopify’s, and what this page verified is the target: the resulting template type-checks, builds and serves as described in the previous section.

The shopify.app.toml reference

shopify.app.toml is the root of the project and the only file the CLI requires. It holds app-level metadata and configuration. The first time app dev or app config link runs, the file is updated to reflect the linked app. Changes are applied automatically by app dev to the chosen development store only; to take effect in production they must be released with shopify app deploy, which creates an app version. Much of this configuration can also be edited in the Dev Dashboard, but a few things live only there: client ID and secrets, the icon and contact email merchants see, app automation tokens, and an Amazon EventBridge destination for webhooks.

The template’s file is almost empty, because linking fills in the rest:

# This file stores configurations for your Shopify app.
client_id = ""

[access_scopes]
scopes = "write_products"

[webhooks]
api_version = "2026-10"

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

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

(The real file also carries the three mandatory compliance subscriptions and an Events subscription as comments.) The template’s shopify.web.toml is separate and covered in the structure section.

Pocket Coffee Tools: a complete file

The following file configures every section at once, using the placeholder client ID of 32 zeros and the reserved test domain example.test. It is a custom-distribution app that reads products and orders, can ask for customer access later, subscribes to three kinds of webhooks, serves an app proxy page at /apps/coffee on the storefront, and links to a preferences page.

# Pocket Coffee Tools: placeholder values only
name = "Pocket Coffee Tools"
client_id = "00000000000000000000000000000000"
handle = "pocket-coffee-tools"
application_url = "https://pocket-coffee-app.example.test"
embedded = true

[access_scopes]
# Comma-separated string, requested at install through Shopify managed installation
scopes = "read_products,write_products,read_orders"
# Requested later, only when the merchant uses the feature that needs them
optional_scopes = ["read_customers"]
use_legacy_install_flow = false

[access.admin]
direct_api_mode = "online"
embedded_app_direct_api_access = true

[auth]
redirect_urls = ["https://pocket-coffee-app.example.test/api/auth"]

[webhooks]
api_version = "2026-10"

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

  [[webhooks.subscriptions]]
  topics = ["orders/create"]
  uri = "/webhooks/orders/create"
  include_fields = ["id", "name", "total_price_set"]

  [[webhooks.subscriptions]]
  compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
  uri = "/webhooks/compliance"

[app_proxy]
url = "https://pocket-coffee-app.example.test/proxy"
subpath = "coffee"
prefix = "apps"

[app_preferences]
url = "https://pocket-coffee-app.example.test/preferences"

[build]
automatically_update_urls_on_dev = true
dev_store_url = "pocket-coffee-dev.myshopify.com"

This file was placed in a project together with the Pocket Coffee discount extension and shopify app build --no-input was run: the CLI validated the configuration offline, built the extension and printed pocket-coffee-tools built!. The file has not been deployed: deploying needs a login. The compliance subscription is only mandatory for apps distributed in the App Store; a custom app may keep or omit it, and the template ships it commented out for that reason.

Global keys

Key Required Meaning

name

Yes

The app’s display name.

handle

No

URL slug of the App Home, as in admin.shopify.com/store/<store>/apps/<handle>/app. Changing it changes the admin URL and breaks existing app links. Only the CLI can set it. The local validator rejects a handle with spaces.

client_id

Yes

The app’s public identifier.

application_url

Yes

Where Shopify loads the app. For an extension-only app it defaults to https://shopify.dev/apps/default-app-home.

embedded

Yes

A boolean. When true the app renders inside the Shopify admin. The string "yes" is rejected.

extension_directories

No

Paths or globs where the CLI looks for extensions; default ["extensions/"].

web_directories

No

Paths or globs where the CLI looks for shopify.web.toml files; default the project root.

[access_scopes]

Key Required Meaning

scopes

Yes

A comma-separated string of access scopes. With Shopify managed installation the merchant approves all of them when installing, and Shopify updates them without calling the app. An array is rejected.

optional_scopes

No

An array of scopes the app may request later, dynamically, through scopes.request in the admin context.

use_legacy_install_flow

No

false or omitted: managed installation, scopes saved in configuration. true: the legacy flow requests scopes through a URL parameter during OAuth, which still works but can leave different scopes on each install.

When scopes change in a new app version, they are not applied to existing installs automatically; merchants approve the new scopes the next time they open the app. Read scopes are implied by write scopes, so request write_products alone if the app needs both.

[access.admin]

direct_api_mode (online or offline; default online) chooses which access token Direct API access uses. The local validator only accepts those two values. embedded_app_direct_api_access (a boolean, default false) enables Direct API access, which lets the app’s front end call the Admin GraphQL API with each request authenticated by App Bridge.

[auth]

redirect_urls (required, an array of valid URLs) are the places users are sent after the authorization code grant. At least one must exist before the app is made public. The local validator requires the key: without it the build reports [auth.redirect_urls]: Required.

[customer_authentication]

Configures OAuth 2.0 for the Customer Account API. redirect_uris (required) are the URLs customers return to after login, javascript_origins (optional, each with protocol and domain) do not currently enable CORS, and logout_urls (optional) are used for OpenID Connect logout. Apps that authenticate with their own client ID this way are public clients using PKCE and receive no refresh token; renewal repeats the authorization flow with prompt=none.

[webhooks] and subscriptions

api_version (required) is the API version used to serialize webhooks. Each entry has:

Key Required Meaning

topics

Yes

Topics to subscribe to, for example orders/create.

compliance_topics

No

customers/redact, customers/data_request, shop/redact. Mandatory for apps distributed in the App Store.

uri

Yes

An HTTPS URL, a relative path starting with /, a Google Pub/Sub URI (pubsub://<project-id>:<topic-id>) or an Amazon EventBridge ARN.

filter

No

A search-syntax filter so only matching webhooks are delivered.

include_fields

No

Fields to include in the payload; all if omitted.

A subscription declared in the TOML file is app-specific (one for every shop that installs the app, synchronized on every deploy). A subscription registered in code is shop-specific and is only refreshed when afterAuth runs, which is rarely during development; the template’s guidance is to declare webhooks in the TOML file. Webhooks created by hand in the Shopify admin fail HMAC validation because they are not signed with the app’s secret.

[events]

api_version (required) is the version used to validate and run subscription queries. Each has handle (required, alphanumeric, _ and -, up to 50 characters), topic (for example Product), actions (create, update, delete), uri, and optional triggers (field paths that narrow update deliveries), query (a GraphQL Admin operation whose result becomes the delivery data) and query_filter. Next Gen Events became generally available with API version 2026-10 and are covered with webhooks on a separate page.

[app_proxy], [pos], [app_preferences] and [build]

Key Required Meaning

app_proxy.url

Yes, if the table exists

The URL of the proxy target on the app server.

app_proxy.subpath

Yes, if the table exists

Letters, digits, underscores and hyphens, up to 30 characters; documented as not allowed to be admin, services, password or login.

app_proxy.prefix

Yes, if the table exists

One of a, apps, community, tools. Prefix and subpath define where the proxy appears on the storefront (/apps/coffee).

pos.embedded

No

Load the POS UI extension or App Home app in Shopify POS.

app_preferences.url

No

URL of the app’s preferences page.

build.automatically_update_urls_on_dev

No

true updates the app URL and redirect URLs on every dev run, for the chosen dev store only; false keeps them (recommended for production apps); omitted, dev prompts.

build.dev_store_url

No

Name of the dev store used to preview the app.

What the local validator checks, and what it leaves to Shopify

shopify app build --no-input validates shopify.app.toml against a schema before building, with no login. The following changes were made to the file above, one at a time, and the outcome recorded:

Change Result of app build

embedded = "yes"

[embedded]: Value must be Boolean

scopes as an array

[access_scopes.scopes]: Expected string, received array

redirect_urls removed

[auth.redirect_urls]: Required

application_url = "not a url"

[application_url]: Invalid URL

direct_api_mode = "sometimes"

[access.admin.direct_api_mode]: Invalid literal value, expected "online"

handle = "Pocket Coffee"

[handle]: String can't contain special characters

subpath = "admin" in [app_proxy]

Accepted offline (the documented restriction is enforced by Shopify when the configuration is released)

prefix = "shop" in [app_proxy]

Accepted offline

api_version = "latest" in [webhooks]

Accepted offline (a real version must be given; Shopify validates it on release)

The template’s untouched shopify.app.toml fails the same check: [auth]: Required, [application_url]: Valid URL is required and [embedded]: Boolean is required, which shows that client_id = "" and a short file are only valid after shopify app config link (or app dev) has filled the rest in.

Several configurations: staging and production

Besides shopify.app.toml, a project can have shopify.app.<config-name>.toml files, each linked to a different app on Shopify. The recommended layout keeps the default shopify.app.toml for the shared development instance and adds named files for staging and production.

shopify app config link                      # create or re-link a configuration; prompts for a name when one exists
shopify app config use staging               # make shopify.app.staging.toml the default for later commands
shopify app dev --config staging             # override the default for one command
shopify app deploy --config production --allow-updates
shopify app config pull                      # refresh a linked configuration without prompts
shopify app config validate                  # validate the configuration and all extension configurations

config link flags include --client-id (required when non-interactive), --file-name and --force (overwrite without prompting). Shopify notes that not every configuration file needs to be committed: depending on the workflow, personal development configurations can be git-ignored and only the production one kept. shopify app config push no longer exists: configuration is released together with extensions by app deploy. app config link, use, pull and validate need a login, and validate and info run in a non-interactive shell stop with a message that app config link requires additional flags in non-interactive terminal environments when the project has no linked app.

App structure on disk

All apps created with Shopify CLI follow one layout, so the CLI can serve and deploy the web app and the extensions together, generate new extensions and manage all dependencies in one place.

File or directory Required Description

shopify.app.toml

Yes

Root of the app and its configuration.

shopify.app.<config-name>.toml

No

Named configurations for other linked apps.

package.json

Yes

Node.js metadata, dependencies and scripts. Extension directories have their own.

shopify.web.toml

No

Marks a directory as web files and tells the CLI how to run it.

app/

Yes (React Router)

Entry points, routes and webhook handlers; entry.server.tsx is the entry and root.tsx the root route (the .jsx variants exist for JavaScript projects).

extensions/

No

One subdirectory per extension; the directory name is the extension’s local identifier. Each contains a shopify.extension.toml.

.env

No

The UUIDs of the app and its extensions; created or updated by shopify app env pull, which preserves existing and commented variables.

.shopify/

No

State the CLI writes during app dev (for example the mkcert binary); ignored by the template.

In older CLI versions the web files lived in a web/ directory; templates now put a React Router app at the project root. The default location of web files is still web/, and any subdirectory or the root works as long as it contains a shopify.web.toml.

shopify.web.toml

roles = ["frontend", "backend"]
webhooks_path = "/webhooks/app/uninstalled"

[commands]
predev = "npm exec prisma generate"
dev = "npm exec prisma migrate deploy && npm exec react-router dev"

(The repository stores a Liquid template, shopify.web.toml.liquid, with the package manager filled in on scaffolding, and a name = "React Router" key; the lines above are the rendered result for npm.)

Property Required Description

roles

No

frontend, backend and background. A single-process app has ["frontend", "backend"]. This replaces the deprecated type.

auth_callback_path

No

Overrides the allowed redirection URLs when the app handles OAuth callbacks on a custom path.

webhooks_path

No

Root of the webhook endpoints. app dev --reset sends an UNINSTALLED request here for the chosen store. Default /api/webhooks.

port

No

Port for this process; a random one is chosen otherwise.

commands.build

No

Run by shopify app build, from the directory of the file.

commands.dev

Yes

Run by shopify app dev.

commands.predev

No

Used by the template to run prisma generate before dev.

If the frontend and the backend are separate processes, each gets its own shopify.web.toml; the CLI starts both and expects the frontend to forward backend traffic to the port in BACKEND_PORT. A background process is for other long-running work. The CLI provides these variables to every process: SHOPIFY_API_KEY (the client ID), SHOPIFY_API_SECRET, HOST and APP_URL (the URL stores load), PORT (and FRONTEND_PORT, SERVER_PORT or BACKEND_PORT depending on the role), and SCOPES. web_directories in shopify.app.toml lists exactly where to look, and prevents a file being loaded twice through a symlink.

Dependencies and workspaces

The root package.json of the template declares "workspaces": { "packages": ["extensions/*"] }, so one install covers the app and every extension, and each extension carries its own package.json. Shopify recommends committing the lock file of the package manager. The CLI builds UI extensions with ESBuild from an entry file named index.{ts,js,tsx,jsx} in the extension directory or src/; Functions run the build.command of their TOML file and expect a Wasm file at dist/index.wasm unless build.path says otherwise; a theme app extension is checked with Theme Check on build.

Extensions in the project

Extensions are added with shopify app generate extension, which writes a new folder under extensions/:

shopify app generate extension --template discount --name pocket-coffee-discount --flavor typescript

--template (-t) and --name (-n) are required when non-interactive, and --flavor takes vanilla-js, react, typescript, typescript-react, wasm or rust where the template offers it (the discount template offers rust, vanilla-js, typescript and wasm; the checkout UI and App Home templates offer Preact only, and the App Home template needs CLI 4.1.0 or later). The command needs a login and was not executed. The templates come from the open-source Shopify/extensions-templates repository, whose templates.json lists 81 entries on 2026-10-27. Most UI templates exist in _legacy, _pre_release and current variants. The current identifiers by group:

Group Template identifiers

UI extensions

checkout_ui, customer_account_ui, app_home, admin_action, admin_block, admin_print, admin_purchase_option, conditional_admin_action, customer_segment_template, product_configuration, discount_details_function_settings, validation_settings_ui, order_routing_location_rule_ui, pos_action, pos_block, pos_smart_grid, post_purchase_ui

Functions

discount (unified), cart_transform, cart_checkout_validation, delivery_customization, payment_customization, fulfillment_constraints, fulfillment_option_generator, local_pickup_delivery_option_generator, pickup_point_delivery_option_generator, order_routing_location_rule, discounts_allocator; order_discounts, product_discounts and shipping_discounts are marked deprecated

Online Store and storefront

theme_app_extension, web_pixel

Flow

flow_action, flow_template, flow_trigger, flow_trigger_lifecycle_callback

Links and Sidekick

admin_link, support_link, subscription_link_extension, app_action_link, discount_app_action_link, app_data, app_action

Payments, channels, tax

card_present_payments, credit_card_payments, custom_credit_card_payments, custom_onsite_payments, offsite_payments, redeemable_payments, channel_config, order_attribution_config, tax_calculation, editor_extension_collection

Every extension has a shopify.extension.toml. The one for Pocket Coffee’s Coffee Club discount (a JavaScript Function built to WebAssembly) is:

api_version = "2026-07"

[[extensions]]
name = "Pocket Coffee discount"
handle = "pocket-coffee-discount"
type = "function"
description = "10 percent off the order subtotal"

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/run.graphql"
  export = "run"

  [extensions.build]
  command = ""
  path = "dist/function.wasm"

Each extension declares its own api_version; the current templates use 2026-07 while 2026-10 is the latest. type is ui_extension, function, theme_app_extension, web_pixel, flow_action and so on; handle is the extension’s identifier; newer templates also write an optional uid into the file. This extension was built with shopify app build --no-input inside the project above and printed "Building GraphQL types…​", "Bundling JS function…​", "Running javy…​" and "Done!", producing dist/function.wasm. The Function page explains the schema, the query and the limits.

An extension-only App Home page is also an extension: app_home generates a ui_extension whose target is admin.app.home.render. App Home UI extensions are available to custom-distribution apps from API version 2026-07; the iframe model remains the recommended default for most apps and for all public apps.

The development loop

shopify app dev builds and previews the app on a dev store and watches for changes. While it runs it creates a dev preview on the chosen store with a file watcher that syncs local configuration and extension changes; serves the web app (for React Router, by running the dev command of shopify.web.toml); starts a local server with hot reload for extensions that support browser preview; runs a reverse proxy to those servers; sets up external networking for the proxy; optionally updates the app URL for that store; and serves GraphiQL for the Admin API with the app’s credentials and scopes. Press p to open the preview URL.

sequenceDiagram actor Dev as Developer participant CLI as Shopify CLI participant DD as Dev Dashboard participant Web as Local web process participant Tun as Cloudflare Quick Tunnel participant Store as Dev store Dev->>CLI: shopify app dev CLI->>DD: log in, read the linked app configuration CLI->>Web: run the dev command with SHOPIFY_API_KEY, SCOPES, APP_URL and PORT CLI->>Tun: open an HTTPS tunnel to the reverse proxy CLI->>Store: create the dev preview, sync configuration and extensions CLI->>Store: update the app URL when automatically_update_urls_on_dev is true Dev->>Store: press p, install the app Store->>Tun: App Home iframe and webhook deliveries Tun->>Web: forward the request

Networking options

Option Behavior

Cloudflare Quick Tunnels (default)

A public HTTPS URL to the local server; works for everything that Shopify calls, including webhooks.

--use-localhost

Serves the app on 127.0.0.1 with a self-signed certificate created with mkcert, through a reverse proxy on port 3458 (--localhost-port changes it, --install-mkcert skips the prompt). Webhooks, app proxy, Flow actions and POS testing do not work in this mode because Shopify cannot reach the machine.

--tunnel-url=https://<tunnel-url>:<port>

Use a tunnel you started yourself, for example ngrok.

Flags worth knowing

-s/--store selects the dev store (it must be a development or Plus sandbox store), -c/--config and --client-id select the configuration or app, --reset forgets the saved organization, app and store, --no-update uses the URL in the TOML instead of an autogenerated one, -t/--theme and --theme-app-extension-port serve a theme app extension, --checkout-cart-url and --subscription-product-url set preview resources, --store-password supplies the storefront password, --notify reports file changes to a file or URL, --unsafe-validation lets dev continue after eligible version-validation failures, and --skip-dependencies-installation is deprecated in favour of workspaces.

When app dev is stopped, the dev preview stays on the store with the app’s configuration and extensions. shopify app dev clean (or Clean dev preview in the Dev Console in the admin) restores the active released version, and anything that exists only in the preview is deleted: a Discount Function added during dev and the discounts that reference it, a UI extension placed in the checkout editor, a metaobject definition declared only in the preview. A team shares one development app instance (the default shopify.app.toml); each developer uses a personal dev store so previews do not collide.

Building, deploying and versions

An app version is a snapshot of the configuration and all extensions. shopify app build runs every build in the project (and Theme Check for a theme app extension); shopify app deploy builds, creates a version and releases it. The command does not deploy the web app.

Flag of app deploy Meaning

--allow-updates

Add and update extensions and configuration without confirmation; the recommended flag for CI.

--allow-deletes

Also remove extensions and configuration without confirmation.

--no-release

Create the version but do not release it.

--no-build

Skip building; the artifacts must already exist.

--version, --message, --source-control-url

Name, internal note and source URL of the version.

-c/--config, --client-id

Deploy a specific configuration, optionally to a different app.

In a non-interactive environment one of --allow-updates, --allow-deletes or --no-release is required. Later, shopify app release --version <name> releases an unreleased version and shopify app versions list shows the deployed ones; an older version can be released again to roll back. Some versions need Shopify review before release. Scopes added in a new version reach existing installs only after the merchant approves them. shopify app info prints the app, the dev store, the project structure, the requested scopes and the CLI and package-manager versions, and shopify app env pull and shopify app env show handle the .env file. A CI pipeline uses the SHOPIFY_APP_AUTOMATION_TOKEN from the Dev Dashboard with shopify app deploy --config production --allow-updates; pipelines belong to the deployment page.

Common mistakes

Symptom Cause and fix

shopify app init stops with "Authorization is required to continue"

The shell is non-interactive and not logged in. Run it in a terminal, or provide credentials through the environment.

Flags not specified for --name, --organization-id or --flavor

Non-interactive runs need --name and --organization-id (or --client-id), --template and, for React Router, --flavor javascript or typescript.

Install fails with an engine error

.npmrc sets engine-strict=true and the template needs Node.js 22.12 or later.

The table main.Session does not exist

Run the setup script (prisma generate && prisma migrate deploy).

@prisma/client has no exported member PrismaClient

Run npx prisma generate before the typecheck.

Every action returns 400 Bad Request behind a TLS proxy

React Router 7.18.3 validates the full origin of an action and react-router-serve does not trust the proxy; the template pins 7.18.2 for this reason.

Navigation inside the admin breaks the session

Inside an iframe use Link from react-router, the redirect returned by authenticate.admin, and useSubmit; do not use <a> or the redirect of React Router.

A webhook route fails or loops

It is nested under app., or authenticate.webhook is not called first. Webhooks may arrive more than once and after the app was uninstalled, so the session can be missing.

Admin-created webhook fails HMAC validation

Only subscriptions defined by the app (TOML) are signed with the app secret.

admin is undefined in a webhook sent by shopify app webhook trigger

The CLI triggers an event for a valid but non-existent shop; that is expected.

"nbf claim timestamp check failed"

The ID token is rejected because the computer clock is out of sync.

Configuration change has no effect in production

app dev applies it only to the dev store; release it with shopify app deploy.

Streaming or Await responses arrive in one chunk during development

The Cloudflare tunnel waits for the stream to finish; use --use-localhost to test streaming.

The Prisma query engine for Windows cannot be loaded on Windows ARM

Set PRISMA_CLIENT_ENGINE_TYPE=binary.

What was and was not executed

Executed on 2026-10-27 on a macOS machine with Node.js 24 and no Shopify account, store, login or credential: CLI 5.0.0 help and flag validation of app init; typecheck, build, lint, prisma migrate deploy and a local run with placeholder environment values on a copy of the public React Router template, including the order-logger route and the signed and tampered webhook requests; shopify app build --no-input on the Pocket Coffee Tools configuration and on nine variants of it; the JavaScript discount extension build.

Not executed because they need a Shopify identity: shopify app init past the login, app dev, app deploy, app release, app config link|pull|use|validate, app generate extension, app versions list, app env, app webhook trigger, store creation, the install of the app on a dev store, and any call to the Admin API. The Remix-to-React-Router migration was read from Shopify’s guide and not run on a Remix app. The Dev Dashboard screens and the App Store review flow were not opened.