Shopify app notes · 01

Shopify App Anatomy

What a Shopify app actually is, what every folder and config file is for, and how a request travels through them. Start here if you have never built one.

React Router v7 Node + Prisma

What a Shopify app really is

This surprises most beginners: a Shopify app is just a normal website that you host yourself. Shopify does not run your code. You build an ordinary web app, put it on a server, and Shopify displays it inside a merchant's admin panel in an invisible frame.

So there are three separate pieces, and confusing them causes most early mistakes:

PieceWho runs itWhat lives there
ShopifyShopifyThe merchant's products, orders, customers.
Your appYouYour pages, your logic, your server.
Your databaseYouAnything Shopify does not store for you.

Your app asks Shopify for data over an API, and stores its own extra data in its own database. When a merchant clicks something in your app, the request goes to your server, not Shopify's.

The one-sentence version

You are building a normal web app that logs in with Shopify, reads and writes Shopify data through an API, and gets displayed inside the Shopify admin.


Words you will see everywhere

These appear constantly and are rarely explained. You do not need to memorise them — just know roughly what they mean when you meet them.

WordPlain meaning
AdminThe Shopify dashboard where a merchant manages their shop. Your app appears inside it.
EmbeddedYour app renders inside the Shopify admin rather than on its own website.
SessionA saved record proving a shop installed your app, including the access token used to call the API.
Access tokenThe secret password your app uses to read and write that shop's data.
ScopeA permission, like read_products. Your app must ask for each one it needs.
WebhookShopify calling your server to say something happened ("an order was placed").
RouteOne file in your app that answers one URL.
LoaderCode that runs on the server to fetch data before a page renders.
ActionCode that runs on the server to save data when a form is submitted.
PrismaThe tool used to talk to your database in JavaScript instead of writing SQL.
MigrationA recorded database change, so every environment can apply the same change.

Loaders and actions get a whole note of their own later. For now: loader reads, action writes.


The map

This is what shopify app init actually gives you — nothing added, nothing removed.

your-app/
├── app/                      ← you live here
│   ├── routes/               pages + endpoints (file name = URL)
│   ├── db.server.js          the database connection
│   ├── shopify.server.js     app config + login helpers
│   ├── routes.js             turns on file-based routing
│   ├── root.jsx              the outer HTML shell
│   └── entry.server.jsx      server rendering — leave it alone
│
├── prisma/
│   ├── schema.prisma         your database tables
│   └── migrations/           generated — never hand-edit
│
├── extensions/               empty until you add an extension
├── public/                   static files served as-is
│
├── shopify.app.toml          app settings Shopify reads
├── .env                      secrets — created for you, never committed
├── Dockerfile                ready for deployment
├── vite.config.js
└── package.json
JavaScript or TypeScript?

shopify app init asks which you want. The tree above — and every example in these notes — is the JavaScript answer, so files end in .js and .jsx.

Choose TypeScript and you get exactly the same files as .ts and .tsx, plus type annotations. Everything in these notes still applies; only the extensions and the types differ.

Folders you will add yourself

The template deliberately starts small. As an app grows, most teams end up creating these — they are conventions, not requirements, and none of them exist until you make them:

app/
├── components/    UI reused on more than one page
├── hooks/         reusable React state logic
├── utils/         plain helper functions
├── graphql/       Shopify queries used in several routes
└── constants/     fixed values
Trap — server.js is not in the template

Many real projects have a server.js at the root, and tutorials often show one. The template does not: its start script is react-router-serve ./build/server/index.js, which runs a server for you.

You only write your own server.js when you need something that server cannot do — typically scheduled background jobs (Note 10), custom middleware, or extra routes. Doing so means swapping start to node server.js.

If you are reading someone's project and find server.js and server/jobs/, that is their addition, not standard scaffolding.

If you remember only four

app/routes/ for anything a user sees, prisma/schema.prisma for your data, shopify.app.toml for app settings, .env for secrets. The rest is scaffolding you rarely open.


How a request travels

Learn this once and debugging gets much easier, because you can tell where it broke.

Browser → the server → React Router → your route file → loader / action → DB or Shopify

React Router picks the route file by matching the URL against the file's name.

In words: a request arrives at your Node server, React Router looks at the URL and picks the matching file in app/routes/, that file's loader or action runs on the server, it fetches or saves data, and the result is sent back.

The three entry points

FileJobDo you edit it?
The serverRuns your built app. react-router-serve by default — or your own server.js if you added oneOnly if you need jobs or middleware
app/root.jsxThe outer <html> page wrapper for everythingRarely
app/routes/app.jsxWraps every admin page: login check + nav menuYes — to add nav links
app/routes/app.jsx
export const loader = async ({ request }) => {
  await authenticate.admin(request);   // login check for every /app/* page
  return { apiKey: process.env.SHOPIFY_API_KEY };
};

export default function App() {
  const { apiKey } = useLoaderData();
  return (
    <AppProvider embedded apiKey={apiKey}>
      <s-app-nav>
        <s-link href="/app/products">Products</s-link>
        <s-link href="/app/settings">Settings</s-link>
      </s-app-nav>
      <Outlet />   // the actual page appears here
    </AppProvider>
  );
}

Why the file naming matters so much

Files are named with dots, and a dot means "nested inside". Every file called app.something.jsx is nested inside app.jsx, so app.jsx's loader runs first.

That means one authenticate.admin call in app.jsx protects every admin page, and you never repeat it.

The useful consequence

A file that does not start with app. is not protected by that login check. That is deliberate and necessary — webhooks and public endpoints are called by Shopify or by shoppers, who have no admin login. Name those files webhooks.* or proxy.* and they stay outside the protected area.


shopify.server.js — the heart of the app

Set up once, used everywhere. Every authenticated thing starts here.

app/shopify.server.js
const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET,
  apiVersion: ApiVersion.October25,       // pin it; don't let it drift
  scopes: process.env.SCOPES?.split(","),
  appUrl: process.env.SHOPIFY_APP_URL,
  sessionStorage: new PrismaSessionStorage(prisma),  // store logins in your DB
});

export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;

Two exports do nearly all the work:

  • authenticate.admin(request) — use inside a loader or action. It checks the merchant is logged in and hands you session (which shop) and admin (the API client for calling Shopify).
  • unauthenticated.admin(shop) — use when there is no request to check, such as a scheduled job at 3am. It builds an API client from the shop's stored token.

sessionStorage is why installs survive restarts: each shop's login is saved as a row in your own database.


Config files

The TOML: settings Shopify reads

A .toml file is just a simple settings format. This one describes your app to Shopify.

shopify.app.toml
client_id = "abc123..."              # which app this is
name = "My App"
application_url = "https://myapp.com"     # where your app is hosted
embedded = true                      # show inside the Shopify admin

[build]
automatically_update_urls_on_dev = true   # CLI rewrites URLs while developing

[webhooks]
api_version = "2026-01"

  [[webhooks.subscriptions]]
  topics = [ "app/uninstalled" ]          # tell me when someone uninstalls
  uri = "/webhooks/app/uninstalled"       # ...by calling this route

[access_scopes]
scopes = "read_products,write_products"   # permissions you're asking for

[auth]
redirect_urls = [ "https://myapp.com/api/auth" ]   # where login returns to

Why there are several TOML files

Development and production are two different Shopify apps with different IDs — so each needs its own settings file:

FileUsed for
shopify.app.tomlLocal development (the default)
shopify.app.staging.tomlA test app
shopify.app.production.tomlThe live app real merchants use
shopify app config use production   # switch which file is active
shopify app deploy                  # push the ACTIVE one to Shopify
Trap — editing one file and deploying another

A setting added to one TOML is not in the others. Add a scope or webhook and you must add it to every config file, or production will quietly lack it.

Habit: run shopify app config validate before deploying, and shopify app info to confirm which app you are actually pointed at.

.env: secrets and settings your code reads

Values that differ per machine, or are too secret to commit:

DATABASE_URL="mysql://user:pass@host:3306/dbname"
SHOPIFY_API_KEY=abc123
SHOPIFY_API_SECRET=shpss_...        # never commit or share this
SHOPIFY_APP_URL=https://myapp.com
SCOPES=read_products,write_products

Read in code as process.env.DATABASE_URL. Commit a .env.example with the names and no values, so the next developer knows what to fill in.

A fresh template has no .env: while you develop, shopify app dev hands your app the key, secret, URL and scopes itself. You write these values yourself on the production server (Note 11).

Trap — scopes live in two places

Notice scopes is in the TOML and SCOPES is in .env — because shopify.server.js reads the env var while your app is running. In development shopify app dev fills it in from the TOML for you; on your production server you must update it yourself. Forgetting the server one creates a bug that appears only in production.


The .server.js rule

To understand this, you need one fact: your app is built into two bundles from the same folder. One runs on the server; one is downloaded into the visitor's browser. Anything a React component imports ends up in the browser bundle — visible to anyone.

Naming a file something.server.js tells the build: "server only — never send this to the browser."

Trap — the leak that breaks the build

Put a database call in a normal helper, import that helper from a component, and your database credentials are now headed for the browser. You usually find out via a confusing build error rather than a clear message.

The fix: split the file in two.

// utils/slug.js — pure, safe anywhere
export function slugify(text) { return text.toLowerCase()...; }

// utils/slug.server.js — touches the DB, server only
import db from "../db.server";
export async function uniqueSlug(text) { /* ... */ }

Rule of thumb: if a file imports db.server or shopify.server, or reads a secret from process.env, its name must end in .server.js.

Loaders and actions are already server-only, so they can import anything safely. It is the shared helpers that catch people out.


The database connection

A tiny file, but the odd-looking part matters:

app/db.server.js
import { PrismaClient } from "@prisma/client";

if (process.env.NODE_ENV !== "production") {
  if (!global.prismaGlobal) global.prismaGlobal = new PrismaClient();
}
const prisma = global.prismaGlobal ?? new PrismaClient();
export default prisma;

In development the server restarts every time you save a file. Without that global trick, each restart would open a fresh batch of database connections until the database refuses new ones. Production starts once, so it simply creates one.

Import it the same way everywhere: import db from "../db.server";


Where your own code goes

FolderPut hereExample
routes/Anything with a URLapp.products.$id.jsx
components/UI used on 2+ pagesProductCard.jsx
hooks/Reusable React state logicuseImageUpload.js
utils/Plain helper functionsformatMoney.js
graphql/Shopify queries used in several routesmutations/fileCreate.js
constants/Fixed valuesplacements.js
server/jobs/Scheduled background worksyncJob.js

A query used in exactly one route can stay inside that route. Move it to graphql/ when a second caller appears — not before.


Folders you should never edit

  • build/ — compiled output. Regenerated every build.
  • .react-router/ — generated types.
  • .shopify/ — the CLI's own notes about your project.
  • node_modules/ — installed packages.
  • prisma/migrations/ — do commit these, but never hand-edit them. Your production database depends on them being exactly as generated.

Scripts you will use

npm run dev      # start developing (runs shopify app dev)
npm run build    # compile for production
npm run start    # serve the compiled app (react-router-serve)
npm run setup    # prisma generate && prisma migrate deploy
npm run lint     # check code style

setup is worth knowing: it rebuilds the database client and applies pending migrations. It runs automatically when a production container starts, and you will run it by hand after pulling schema changes.


Joining an existing app: a 15-minute orientation

Do this in order and you will understand an unfamiliar Shopify app surprisingly fast.

  • Open prisma/schema.prisma. The tables tell you what the app actually does.
  • List app/routes/. The file names are the site map.
  • Open app/routes/app.jsx to see the nav and how pages are protected.
  • Read shopify.app.toml: scopes tell you what it touches, webhooks tell you what it reacts to.
  • Count the TOML files. More than one means multiple environments — find out which is production.
  • Compare .env.example with your .env and fill the gaps.
  • Run npm install, then npx prisma generate, then npm run dev.

Quick answers

Why are there two "root" files, root.jsx and app.jsx?

root.jsx is the HTML document wrapping every page, including public ones. app.jsx is the layout for the embedded admin pages only — it adds the login check, App Bridge and the nav.

That split is what allows public routes (webhooks, app proxy endpoints, a marketing page) that must work without a Shopify login.

Does my app need a database?

Yes — at minimum to store sessions, so shops stay installed between restarts. Beyond that, store anything Shopify has no place for: your own settings, configurations, job state.

Data that belongs to a product (like a custom field) is often better stored on Shopify as a metafield, so the storefront can read it.

I changed schema.prisma and nothing happened

Run npx prisma migrate dev --name what_changed. That writes the migration and updates the client. Editing the file alone changes nothing in your database.

I changed the TOML and nothing happened

TOML changes reach Shopify through shopify app deploy. While shopify app dev is running they are applied automatically, but only to your development store.

Do I commit .env?

No — it holds your API secret. Commit .env.example with the variable names and empty values instead.