Build Your First App
One small, real app built start to finish — every piece from the earlier notes, assembled. Follow it on your own dev store and you will have shipped a working Shopify app by the end.
What we are building
Product Badges. A merchant picks a product and gives it a badge — "New", "Bestseller", "Last few" — with a colour. The app:
- lists every badge in the admin, and lets the merchant add or remove them;
- stores each badge in your database, scoped to the shop;
- writes it to the product as a metafield, so a theme can display it;
- cleans up automatically when a product is deleted, via a webhook.
Small enough to finish in an afternoon, and it uses almost everything you have learned.
| Step | You practise | Explained in |
|---|---|---|
| 1–2 | Creating the app, scopes | 01, 02, 04 |
| 3 | A database table | 05 |
| 4–5 | Nav, list page, loader, delete | 03, 07, 09 |
| 6 | Resource picker, action, Admin API | 06, 07, 09 |
| 7 | A webhook | 10 |
| 8–9 | Testing and shipping | 11, 13 |
Type the code rather than pasting it. When a line surprises you, open the note in the right-hand column. The goal is not a badges app — it is the confidence that you can build the next one alone.
Before you start
- Node.js 20 or newer (
node -v). - Shopify CLI installed and logged in (
shopify auth login). - Access to an organization with the App developer role.
- A dev store with demo data:
shopify store create dev --name "Badges test" --demo-data
Create the app
shopify app init
Choose the React Router template and name it product-badges. Then start it:
shopify app init will ask whether you want JavaScript or TypeScript. Pick JavaScript so your files match the code below exactly.
If you prefer TypeScript, everything here still works — name the files .tsx instead of .jsx and add types as you go.
cd product-badges
shopify app dev
Pick your dev store when asked. On a dev store the CLI installs the app for you — the terminal says Access scopes auto-granted. Open the Preview URL it prints; if the browser asks you to install or approve permissions, accept. You should see the template's welcome page inside the Shopify admin.
shopify app dev stays open in its own terminal the whole time. Use a second terminal for the other commands below.
Ask for permission to edit products
Writing a metafield on a product needs the write_products scope (Note 04). The template already asks for it — open the TOML and check:
[access_scopes]
scopes = "write_products,write_metaobjects,write_metaobject_definitions"
write_products includes read access, so there is nothing to add. Do not delete the other two scopes — the template's own welcome page uses them for its "Generate a product" demo.
Add it to that line and save. On a dev store, the running shopify app dev picks up the change and grants it automatically — watch the terminal for Access scopes auto-granted. There is no .env to edit while developing: the CLI hands the scopes to your app. On a real server you set SCOPES yourself (Note 11).
Add the database table
Add this model to the end of the file. Leave model Session exactly as it is.
model Badge {
id Int @id @default(autoincrement())
shop String // e.g. "my-store.myshopify.com"
productGid String
productTitle String
label String
color String @default("#0a6b57")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([shop, productGid]) // one badge per product, per shop
}
npx prisma migrate dev --name add_badges
Decisions worth noticing, all from Note 05:
shopties each badge to one shop. Every query will filter on it, usingsession.shop(Note 04).@@unique([shop, productGid])stops duplicate badges on a product — and lets usupsertlater.- No relation to
Session— on purpose. The warning below explains why.
Linking Badge to Session with @relation looks tidy, but the template deletes a shop's Session row whenever the app is uninstalled — and that link breaks uninstall either way.
With onDelete: Cascade, deleting the session silently erases every badge the shop ever made. With no onDelete, Prisma uses Restrict: the database refuses to delete the session, and the uninstall webhook crashes with Foreign key constraint violated — again on every retry Shopify sends. The badges look fine, so it is easy to miss.
Storing the shop's domain instead keeps the badges for a reinstall and lets uninstall clean up the session properly.
Add it to the navigation
The template's navigation already has two links. Add the third one below them:
app/routes/app.jsx<s-app-nav>
<s-link href="/app">Home</s-link>
<s-link href="/app/additional">Additional page</s-link>
<s-link href="/app/badges">Badges</s-link> // add this line
</s-app-nav>
The list page
Shows every badge for this shop, with a remove button on each.
app/routes/app.badges._index.jsximport { useEffect } from "react";
import { useFetcher, useLoaderData } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const badges = await db.badge.findMany({
where: { shop: session.shop }, // only THIS shop
orderBy: { createdAt: "desc" },
select: { id: true, productTitle: true, label: true, color: true },
});
return { badges };
};
export const action = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const form = await request.formData();
if (form.get("intent") !== "delete") return { ok: false, message: "Unknown action" };
const badge = await db.badge.findFirst({
where: { id: Number(form.get("id")), shop: session.shop },
});
if (!badge) return { ok: false, message: "Badge not found" };
// Remove it from Shopify first...
const res = await admin.graphql(
`#graphql
mutation DeleteBadge($metafields: [MetafieldIdentifierInput!]!) {
metafieldsDelete(metafields: $metafields) {
deletedMetafields { key }
userErrors { field message }
}
}`,
{ variables: { metafields: [{ ownerId: badge.productGid, namespace: "custom", key: "badge" }] } },
);
const errors = (await res.json()).data?.metafieldsDelete?.userErrors ?? [];
if (errors.length) return { ok: false, message: errors[0].message };
// ...then from our database.
await db.badge.deleteMany({ where: { id: badge.id, shop: session.shop } });
return { ok: true, message: "Badge removed" };
};
function BadgeRow({ badge }) {
const fetcher = useFetcher(); // one per row (Note 07)
const shopify = useAppBridge();
const busy = fetcher.state !== "idle";
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.message) {
shopify.toast.show(fetcher.data.message, { isError: !fetcher.data.ok });
}
}, [fetcher.state, fetcher.data, shopify]);
return (
<s-stack direction="inline" gap="base" justifyContent="space-between" alignItems="center">
<s-stack direction="inline" gap="small" alignItems="center">
<span style={{ background: badge.color, color: "#fff", padding: "2px 10px",
borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
{badge.label}
</span>
<s-text>{badge.productTitle}</s-text>
</s-stack>
<s-button
tone="critical"
variant="tertiary"
disabled={busy}
onClick={() => fetcher.submit(
{ intent: "delete", id: String(badge.id) },
{ method: "post" },
)}
>{busy ? "Removing…" : "Remove"}</s-button>
</s-stack>
);
}
export default function Badges() {
const { badges } = useLoaderData();
return (
<s-page heading="Badges">
<s-section>
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
<s-text>{badges.length} {badges.length === 1 ? "badge" : "badges"}</s-text>
<s-button href="/app/badges/new" variant="primary">Add badge</s-button>
</s-stack>
</s-section>
<s-section heading="All badges">
{badges.length === 0 ? (
<s-text color="subdued">No badges yet. Add one to get started.</s-text>
) : (
<s-stack gap="base">
{badges.map((b) => <BadgeRow key={b.id} badge={b} />)}
</s-stack>
)}
</s-section>
</s-page>
);
}
If the Shopify call fails, we return early and the database is untouched — so the two never disagree. Do it the other way round and a failed API call leaves a row in your list that no longer exists on the product.
The create page
The merchant picks a product with Shopify's own picker, types a label, chooses a colour, and saves.
app/routes/app.badges.new.jsximport { useEffect, useState } from "react";
import { redirect, useFetcher } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }) => {
const { admin, session } = await authenticate.admin(request);
const { productGid, productTitle, label, color } = await request.json();
// Validate on the server — always (Note 07).
const cleanLabel = (label ?? "").trim();
if (!productGid) return { ok: false, message: "Choose a product" };
if (!cleanLabel) return { ok: false, message: "Enter a badge label" };
if (cleanLabel.length > 24) return { ok: false, message: "Keep the label under 24 characters" };
if (!/^#[0-9a-f]{6}$/i.test(color ?? "")) return { ok: false, message: "Choose a valid colour" };
// 1. Write to Shopify, and CHECK userErrors (Note 06).
const res = await admin.graphql(
`#graphql
mutation SetBadge($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) {
metafields { id }
userErrors { field message }
}
}`,
{
variables: {
metafields: [{
ownerId: productGid,
namespace: "custom",
key: "badge",
type: "json",
value: JSON.stringify({ label: cleanLabel, color }),
}],
},
},
);
const errors = (await res.json()).data?.metafieldsSet?.userErrors ?? [];
if (errors.length) return { ok: false, message: errors[0].message };
// 2. Save to our database. upsert = create, or update if it exists.
await db.badge.upsert({
where: { shop_productGid: { shop: session.shop, productGid } },
update: { label: cleanLabel, color, productTitle },
create: { shop: session.shop, productGid, productTitle, label: cleanLabel, color },
});
return redirect("/app/badges");
};
export default function NewBadge() {
const fetcher = useFetcher();
const shopify = useAppBridge();
const [product, setProduct] = useState(null);
const [label, setLabel] = useState("");
const [color, setColor] = useState("#0a6b57");
const busy = fetcher.state !== "idle";
// Show server-side validation errors as a toast.
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data && !fetcher.data.ok) {
shopify.toast.show(fetcher.data.message, { isError: true });
}
}, [fetcher.state, fetcher.data, shopify]);
async function pickProduct() {
const selected = await shopify.resourcePicker({ type: "product", multiple: false });
if (!selected?.length) return; // cancelled
setProduct({ id: selected[0].id, title: selected[0].title });
}
function save() {
fetcher.submit(
{ productGid: product?.id, productTitle: product?.title, label, color },
{ method: "post", encType: "application/json" },
);
}
return (
<s-page heading="Add badge">
<s-section>
<s-stack gap="base">
<s-stack direction="inline" gap="base" alignItems="center">
<s-button onClick={pickProduct}>
{product ? "Change product" : "Choose product"}
</s-button>
{product && <s-text>{product.title}</s-text>}
</s-stack>
<s-text-field
label="Badge label"
details="Shown on the product, e.g. New or Bestseller"
value={label}
onChange={(e) => setLabel(e.currentTarget.value)}
/>
<label style={{ display: "flex", gap: 10, alignItems: "center" }}>
Colour
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<s-stack direction="inline" gap="small">
<s-button variant="primary" disabled={busy} onClick={save}>
{busy ? "Saving…" : "Save badge"}
</s-button>
<s-button href="/app/badges">Cancel</s-button>
</s-stack>
</s-stack>
</s-section>
</s-page>
);
}
Things this page does deliberately:
- Controlled fields +
fetcher.submit, because Polaris components do not submit natively (Note 07). - JSON submission, so the data arrives with its real shape.
- The picker only chooses — we need just the id and title, so its truncated data is fine here (Note 09).
- A disabled button while saving, so a double-click cannot create two badges.
productGid from the browser?
Here, yes. The Admin API call runs with this shop's token, so a product id belonging to another shop simply fails with a userError. The rule is: never let a browser value choose which shop you act for — that always comes from the session.
Clean up when a product is deleted
If the merchant deletes a product in Shopify, its badge should vanish from our list too. Add this subscription below the two the template already has under [webhooks]:
[[webhooks.subscriptions]]
topics = [ "products/delete" ]
uri = "/webhooks/products/delete"
app/routes/webhooks.products.delete.jsx
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }) => {
const { shop, payload } = await authenticate.webhook(request);
const productGid =
payload.admin_graphql_api_id ?? `gid://shopify/Product/${payload.id}`;
await db.badge.deleteMany({
where: { productGid, shop },
});
return new Response();
};
This handler follows every rule from Note 10: it verifies the request, it answers in milliseconds, it is safe to run twice (deleteMany on a row that is already gone does nothing), and the filename does not start with app..
Test it properly
- Add a badge. It appears in the list.
- Open that product in the Shopify admin and find the
custom.badgemetafield. - Add a badge to the same product again. The list shows one, updated — not two.
- Submit with an empty label. You get an error toast and nothing is saved.
- Remove a badge. The metafield disappears from the product too.
- Create a throwaway product, give it a badge, then delete the product in Shopify. Within a few seconds its badge disappears from the list. (
shopify app webhook triggercan also send aproducts/delete, but its sample payload uses a made-up product id — it proves the route answers, not that a real badge is removed.) - Install on a second dev store. It must show none of the first store's badges.
- Uninstall the app, then reinstall it by restarting
shopify app dev. The badges are still there — and the terminal showsReceived APP_UNINSTALLED webhookwith no error after it.
It is the only test that catches a missing shop in a query (Note 04). With one store everything looks correct; with two, a scoping bug is obvious immediately.
Ship it
Follow Note 11 to deploy to a real server, then Note 13 to install it on a real store. In short:
# code → your server
git push && docker compose up -d --build
# settings (scopes + webhook) → Shopify
shopify app config use production
shopify app info
shopify app deploy
# then: Dev Dashboard → Distribution → generate an install link
What you just built
That shape — admin UI → validated action → Shopify + your database → webhooks keeping them in sync — is the skeleton of most real Shopify apps. Bigger apps have more pages and more tables, not a different shape.
Where to take it next
| Idea | Note |
|---|---|
Show the badge on the storefront with a theme app extension — the block reads product.metafields.custom.badge.value | 17 |
| Charge merchants for it with Shopify App Pricing | 16 |
| Add the compliance webhooks so it can go on the App Store | 15 |
Add an edit page — reuse the create page's action with upsert | 03, 07 |
| Let merchants upload a badge icon | 08 |
When it does not work
db.badge is undefined
The Prisma client is stale. Run npx prisma generate and restart shopify app dev.
The app shows a blank page or a 404 inside the admin
Two usual causes. You uninstalled it — restart shopify app dev, which reinstalls it on a dev store. Or shopify app dev stopped, or your computer slept — the temporary public address (the tunnel) it created no longer works. Stop it with Ctrl+C and start it again: it makes a new address and updates the app's settings automatically.
Uninstalling logs Foreign key constraint violated
Your Badge model has a @relation to Session, so the template's uninstall webhook cannot delete the session. Remove the relation and use a plain shop column as in step 3, then run npx prisma migrate dev again. If Prisma says the change cannot be applied to existing rows, run npx prisma migrate reset — it wipes your local development database, test badges included, and the app logs itself in again the next time you open it.
npm run lint reports react/prop-types errors
The template's lint rules want every component's props declared with the prop-types package, and BadgeRow does not. It does not affect how the app runs. To silence it for this file, add /* eslint-disable react/prop-types */ as its first line.
Saving says "access denied" or a scope error
The write_products scope is missing from scopes in shopify.app.toml. Add it back, save, and check the shopify app dev terminal says Access scopes auto-granted. If the browser asks you to approve new permissions, accept.
The save button does nothing
Open the browser console. Most often the picker was cancelled so product is null — the server then returns "Choose a product", which should appear as a toast. If no toast appears, check the useEffect is watching fetcher.data.
The webhook never fires
Restart shopify app dev after editing the TOML, and confirm the filename matches the uri exactly: /webhooks/products/delete → webhooks.products.delete.jsx. Test with shopify app webhook trigger.
My console.log in the action shows nothing in the browser
Correct — loaders and actions run on the server, so their logs appear in the terminal running shopify app dev. Note 18 covers debugging in detail.