Shopify app notes · 14

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.

hands-on ~3 hours

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.

StepYou practiseExplained in
1–2Creating the app, scopes01, 02, 04
3A database table05
4–5Nav, list page, loader, delete03, 07, 09
6Resource picker, action, Admin API06, 07, 09
7A webhook10
8–9Testing and shipping11, 13
How to use this

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

1

Create the app

shopify app init

Choose the React Router template and name it product-badges. Then start it:

When it asks: choose JavaScript

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.

Leave this running

shopify app dev stays open in its own terminal the whole time. Use a second terminal for the other commands below.

2

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:

shopify.app.toml
[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.

Adding a scope later

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).

3

Add the database table

Add this model to the end of the file. Leave model Session exactly as it is.

prisma/schema.prisma
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:

  • shop ties each badge to one shop. Every query will filter on it, using session.shop (Note 04).
  • @@unique([shop, productGid]) stops duplicate badges on a product — and lets us upsert later.
  • No relation to Session — on purpose. The warning below explains why.
Do not link this table to Session

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.

4

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>
5

The list page

Shows every badge for this shop, with a remove button on each.

app/routes/app.badges._index.jsx
import { 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>
  );
}
Why Shopify first, then the database

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.

6

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.jsx
import { 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.
Is it safe to trust 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.

7

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]:

shopify.app.toml
  [[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..

8

Test it properly

  • Add a badge. It appears in the list.
  • Open that product in the Shopify admin and find the custom.badge metafield.
  • 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 trigger can also send a products/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 shows Received APP_UNINSTALLED webhook with no error after it.
The second-store test matters most

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.

9

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

merchant picks a product → action validates → metafield on Shopify → row in your DB
product deleted → webhook → row removed

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

IdeaNote
Show the badge on the storefront with a theme app extension — the block reads product.metafields.custom.badge.value17
Charge merchants for it with Shopify App Pricing16
Add the compliance webhooks so it can go on the App Store15
Add an edit page — reuse the create page's action with upsert03, 07
Let merchants upload a badge icon08

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.