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.
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:
| Piece | Who runs it | What lives there |
|---|---|---|
| Shopify | Shopify | The merchant's products, orders, customers. |
| Your app | You | Your pages, your logic, your server. |
| Your database | You | Anything 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.
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.
| Word | Plain meaning |
|---|---|
| Admin | The Shopify dashboard where a merchant manages their shop. Your app appears inside it. |
| Embedded | Your app renders inside the Shopify admin rather than on its own website. |
| Session | A saved record proving a shop installed your app, including the access token used to call the API. |
| Access token | The secret password your app uses to read and write that shop's data. |
| Scope | A permission, like read_products. Your app must ask for each one it needs. |
| Webhook | Shopify calling your server to say something happened ("an order was placed"). |
| Route | One file in your app that answers one URL. |
| Loader | Code that runs on the server to fetch data before a page renders. |
| Action | Code that runs on the server to save data when a form is submitted. |
| Prisma | The tool used to talk to your database in JavaScript instead of writing SQL. |
| Migration | A 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
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
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.
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.
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
| File | Job | Do you edit it? |
|---|---|---|
| The server | Runs your built app. react-router-serve by default — or your own server.js if you added one | Only if you need jobs or middleware |
app/root.jsx | The outer <html> page wrapper for everything | Rarely |
app/routes/app.jsx | Wraps every admin page: login check + nav menu | Yes — to add nav links |
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.
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.
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 yousession(which shop) andadmin(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.
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:
| File | Used for |
|---|---|
shopify.app.toml | Local development (the default) |
shopify.app.staging.toml | A test app |
shopify.app.production.toml | The live app real merchants use |
shopify app config use production # switch which file is active
shopify app deploy # push the ACTIVE one to Shopify
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).
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."
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:
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
| Folder | Put here | Example |
|---|---|---|
routes/ | Anything with a URL | app.products.$id.jsx |
components/ | UI used on 2+ pages | ProductCard.jsx |
hooks/ | Reusable React state logic | useImageUpload.js |
utils/ | Plain helper functions | formatMoney.js |
graphql/ | Shopify queries used in several routes | mutations/fileCreate.js |
constants/ | Fixed values | placements.js |
server/jobs/ | Scheduled background work | syncJob.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.jsxto 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.examplewith your.envand fill the gaps. - Run
npm install, thennpx prisma generate, thennpm 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.