Examples
Apps/Assistant and plans/Examples

Examples

Seven complete apps in the repository's examples/apps folder: product reviews with moderation, a customer wishlist, an automatic product tagger, a product quiz, a sync of products to Notion, product bundles with a Shopify Function, and a web app that is the backend of a Shop Mini. Each is the starter plus a handful of files, and each is a recipe a new app can start from.

Start from a recipe

Every example is a recipe on New app: choose it under Start from and the app begins with the example's files. A Shopify recipe's scopes are filled in on the connect step and its admin pages decide whether they're Polaris; a web recipe decides the starter. The setup page lists what's left to do and the keys the app reads. ib-apps create <name> --recipe reviews and recipe: "reviews" to POST /v1/apps do the same; the ids are the folder names below (reviews, wishlist, quiz, tagger, notion-sync, bundler, parcel-globe). A Shopify recipe lies over the starter, replacing files of the same path and dropping the starter's storefront block; a web recipe replaces the starter, keeping the coding-agent guidance. Everything stays editable.

Load an example by hand

To put an example over an app you already have: connect Shopify (for a Shopify example), then pull the app, copy the example over it, and sync:

ib-apps pull --app <app>
cp -R examples/apps/reviews/. <app>/
cd <app> && ib-apps dev

ib-apps dev pushes everything to development. Saving extensions/ releases the block to the development Shopify app by itself; Logs show a succeeded config release.

Reviews

Shoppers write reviews from a block on the product page. The brand approves or rejects each one in the Shopify admin, and approved reviews appear on the product page, rendered by Shopify with the page: no script loads them.

PieceWhere
The review model: rating, title, body, author, the product's gid, the store, and a status (pending, approved, rejected) with who moderated it, when, and a note. It belongs to shopifyProduct when the app syncs products.api/models/review/schema.ib.ts
A shopper's review is always created pending, whatever was sent.actions/create.ts
Approve and Reject set the status and moderation fields, then republish the product's approved reviews to Shopify in onSuccess. Delete takes an approved review down too.actions/approve.ts, reject.ts, delete.ts
Approved reviews live in product metafields in the app-reserved namespace $app:reviews: list (the newest 50, JSON, kept under 64 KB) and summary ({ average, count }).api/lib/reviews.ts
Install and reinstall create the two metafield definitions, with storefront read access.shopifyShop/actions/install.ts, reinstall.ts
The Product reviews block reads product.metafields["$app:reviews"] and shows the stars, the count and the reviews. Its form posts to the app proxy.extensions/reviews/
The proxy route checks the request came through the storefront, validates the review and runs create.web/app/proxy/reviews/route.ts
The admin page is the moderation queue. Its routes check the session token before reading or changing anything, and show only the signed-in store's reviews.web/app/page.tsx, moderation.tsx, api/reviews/
Shopify's privacy webhooks: customers/redact deletes the customer's reviews (by customer id, or a guest's email) and republishes the products that showed one; customers/data_request emails the store's owner the customer's reviews through the app's own Resend account (RESEND_API_KEY, EMAIL_FROM).api/actions/shopifyCustomersRedact.ts, shopifyCustomersDataRequest.ts, api/lib/privacy.ts
Tests of the actions against in-memory records, with the Admin API mocked: new reviews start pending, validations, publishing and taking down, a refused publish, customers/redact, and the install's metafield definitions. Run npm install, then ib-apps test.tests/reviews.test.ts, vitest.config.ts
export async function POST(request: Request) {
  const proxy = await verifyProxyRequest(request);
  if (!proxy) return Response.json({ error: "Not found" }, { status: 404 });
  // …validate the form: 1–5 stars, lengths, a honeypot field…
  const api = createClient();
  // One review per shopper per product: a signed-in customer by id, a guest by email.
  const existing = await api.review.findFirst({ filter: { shop: { equals: proxy.shop }, productGid: { equals: productGid }, status: { in: ["pending", "approved"] }, ...who } });
  if (existing) return Response.json({ error: "You've already reviewed this product. Thanks!" }, { status: 409 });
  try {
    await api.review.run("create", { shop: proxy.shop, params: { productGid, shop: proxy.shop, rating, title, body, authorName, authorEmail, customerId: proxy.customerId } });
  } catch (e) {
    // The model's validations have the last word; their problems go back beside the fields.
    if (e instanceof ApiError && e.status < 500) return Response.json({ error: e.message, problems: Object.fromEntries(e.problems.map((p) => [p.field, p.message])) }, { status: 400 });
    throw e;
  }
  return Response.json({ ok: true, status: "pending" }, { status: 201 });
}

Setup after loading:

  1. On Settings → Shopify → Edit scopes and data models, give Products Write: writing product metafields needs write_products. Optionally tick the shopifyProduct model, so reviews link to synced products and the queue shows product titles. Save.
  2. On Installs, use Update scopes for each store already installed, or install on a development store. The install or reinstall creates the metafield definitions.
  3. In the theme editor, add Product reviews to the product template. It finds the app proxy by itself.

Try the reviews app

  • Submit a review from a product page. The form says it'll appear once approved, and Logs show review.create.
  • Open the app in the Shopify admin (or Preview): the review is under Waiting. Approve it. Logs show review.approve and a graphql PublishReviews call, and the product page shows the review and the stars on reload.
  • Rejecting an approved review takes it down; the summary's count and average follow.
  • The same shopper (customer, or email for guests) can't review a product twice while a review is waiting or live.

Wishlist

Signed-in customers save products with a heart on product pages and keep them on a wishlist page rendered in the store's own theme. It needs no Shopify scopes: wishlists live in the app's records, and product details come from Liquid on the storefront.

FileWhat it does
api/models/wishlistItem/schema.ib.tsOne saved product, with a unique key (<shop>:<customerId>:<productGid>) so a product is saved once.
actions/create.ts, delete.tsSave (sets key and addedAt) and remove.
extensions/wishlist/The Wishlist button block. Shopify renders it at its final size in the "not saved" state; its script asks the app whether this customer saved the product, then saves or removes it. Guests see "Sign in to save".
web/lib/wishlist.tsWhat the proxy routes share: the signed-in customer from verifyProxyRequest, reads and writes through createClient(), and the checks every value passes. At most 200 products.
web/app/proxy/wishlist/route.tsThe wishlist page, answered as application/liquid. Liquid looks each product up with all_products (20 per render, so 20 per page).
proxy/wishlist/state/route.tsGET ?product=<gid> answers { saved }, for the button.
proxy/wishlist/items/route.tsPOST { op: "add" | "remove", product, handle } answers { saved }, for the button and the page's Remove. JSON only, so a form on another site can't post here as a signed-in customer.
web/app/page.tsx, summary.tsx, api/summary/route.tsThe admin page: how many products are saved, by how many customers, and the 20 most wishlisted. Its route checks the session token and reads only the signed-in store's records.
api/actions/shopifyCustomersRedact.ts, shopifyCustomersDataRequest.tsShopify's privacy webhooks: delete the customer's wishlist, or email it to the store's owner (api/lib/privacy.ts, with the app's own RESEND_API_KEY and EMAIL_FROM).
export const schema: ModelSchema = {
  type: "ib/model-schema/v1",
  fields: {
    shop: { type: "string", storageKey: "wlShop", validations: { required: true } },
    customerId: { type: "string", storageKey: "wlCustomer", validations: { required: true } },
    productGid: { type: "string", storageKey: "wlProduct", validations: { required: true, regex: { pattern: "^gid://shopify/Product/\\d+$" } } },
    productHandle: { type: "string", storageKey: "wlHandle", validations: { required: true, regex: { pattern: "^[a-z0-9][a-z0-9-]*$" } } },
    variantGid: { type: "string", storageKey: "wlVariant" },
    addedAt: { type: "dateTime", storageKey: "wlAddedAt" },
    key: { type: "string", storageKey: "wlKey", validations: { required: true, unique: true } },
  },
};

After loading, add Add block → Apps → Wishlist button to a product template in the theme editor.

Try the wishlist app

  • As a guest, the block says "Sign in to save", and /apps/<subpath>/wishlist asks you to sign in.
  • Signed in, the heart fills when tapped and stays filled on reload. Logs show the proxy request and a wishlistItem.create run; tapping again runs wishlistItem.delete.
  • The wishlist page lists saved products with image, title and price in the theme's look, and pages on past 20.
  • Saving the same product from two tabs at once leaves one record: the unique key refuses the second.

Both answer customers/redact and customers/data_request in their own actions, as a public app must for Shopify's review: Inference Bureau deletes synced data itself, but only the app knows which of its own records hold a customer's data. Without email set up, a data request's action fails and is retried for a few hours, then the privacy event in Logs says to answer by hand. The wishlist has no tests yet; the other four do (see Testing actions).

Product tagger

The merchant writes rules on the admin page ("products that mention merino get the tag Wool"); every product created or changed gets its rules' tags, and Tag every product applies them to the whole catalog. It shows webhook triggers, a long job split into pages, and work that's safe to repeat under Shopify's rate limits. Scopes: read_products, write_products.

PieceWhere
The tagRule model: keyword, tag, on or off, and a unique key per store.api/models/tagRule/
products/create and products/update queue tagProduct with only the fields it reads (includeFields).settings.ib.ts, api/actions/tagProduct.ts
A rule matches whole words in the title, description, vendor or type. Only missing tags are added, with one tagsAdd; when none are missing nothing is called, which is what stops the products/update the tagging itself causes.api/lib/tagger.ts
retagAll tags 50 products per job and queues the next page with its cursor, under one concurrencyKey per store. Its calls are background calls; a page still throttled after Inference Bureau's retries throws and is tried again.api/actions/retagAll.ts
The rules page; every route checks the session token and uses session.api.web/app/rules.tsx, web/app/api/
No customer data: customers/data_request says so in Logs instead of waiting as action needed.api/actions/shopifyCustomersDataRequest.ts
export const settings: AppSettings = {
  type: "ib/settings/v1",
  platform: "shopify",
  webhooks: [
    { topic: "products/create", action: "tagProduct", includeFields: ["admin_graphql_api_id", "title", "body_html", "vendor", "product_type", "tags"] },
    { topic: "products/update", action: "tagProduct", includeFields: ["admin_graphql_api_id", "title", "body_html", "vendor", "product_type", "tags"] },
  ],
};

Product quiz

The merchant writes questions in the admin and points each answer at products; shoppers take the quiz from a theme block and land on their matches, rendered in the theme. It needs no Shopify scopes: the block renders from an app-data metafield, and the app proxy does the rest.

PieceWhere
The quiz model (one per store) with questions as JSON, checked on save; ids are kept across edits and never reused.api/models/quiz/, api/lib/quiz.ts
Publish writes the questions, without their products, to app.metafields.quiz.definition on the installation; Unpublish deletes it; an edit republishes a live quiz, and so does a reinstall.actions/publish.ts, unpublish.ts, update.ts, shopifyShop/actions/reinstall.ts
The Product quiz block is a plain form rendered in Liquid, posting to the app proxy. No script.extensions/quiz/
The proxy runs quizResponse.create, which scores the answers against the live quiz (top 3 products), then redirects to a results page answered as Liquid with all_products.web/app/proxy/quiz/, api/models/quizResponse/
Privacy: a signed-in customer's responses are erased on customers/redact and emailed to the store on customers/data_request.api/actions/shopifyCustomers*.ts
{%- liquid
  assign quiz = app.metafields.quiz.definition.value
  assign proxy = app.metafields.ib_app.proxy_path.value
-%}
{%- if quiz.questions.size > 0 and proxy != blank -%}
<form class="ibq" method="post" action="{{ proxy | escape }}/quiz">
  <input type="hidden" name="return_to" value="{{ request.path | escape }}">
  {%- for q in quiz.questions -%}
    <fieldset><legend>{{ q.text | escape }}</legend>
      {%- for a in q.answers -%}
        <label><input type="radio" name="q_{{ q.id | escape }}" value="{{ a.id | escape }}" required> {{ a.text | escape }}</label>
      {%- endfor -%}
    </fieldset>
  {%- endfor -%}
  <button type="submit" class="button">{{ block.settings.submit_label | escape }}</button>
</form>
{%- endif -%}

Notion sync

Every product gets a page in the merchant's Notion database, updated as it changes and archived when it's deleted. It shows a synced model's own actions driving an outside API, jobs that run one at a time per store, and retrying only what the outside API says to retry. Scope: read_products, with the shopifyProduct model synced.

PieceWhere
shopifyProduct's create, update and delete queue syncProduct in onSuccess, with concurrencyKey: notion:<shop>.api/models/shopifyProduct/actions/
The product's page is found by its Shopify ID property each time, so a repeated job updates the page it made. Rate limits and Notion's errors throw and retry; a refused token is kept as lastError for the admin page instead.api/actions/syncProduct.ts, api/lib/notion.ts
The notionConnection model keeps the integration token as an encryptedString, never sent back to the page.api/models/notionConnection/
syncAll queues a sync for every synced product (backfill doesn't run actions), and checkNotion checks the token, the database and its properties.api/actions/syncAll.ts, checkNotion.ts
// api/models/shopifyProduct/actions/update.ts
export const onSuccess: ActionOnSuccess = async ({ record, api }) => {
  // One sync per store at a time: under Notion's rate limit, and two quick edits can't both make the page.
  await api.enqueue("syncProduct", { productGid: record.shopifyId }, { shop: record.shop, concurrencyKey: `notion:${record.shop}`, retries: 8 });
};

Bundler

Product bundles: a variant the store sells, expanded at checkout into the variants it's made of, with an optional discount. It shows a Shopify Function in an app: written in extensions/, built with the Shopify CLI and released by Inference Bureau with the rest of the app, fed by metafields the app's actions write, and turned on for each store on install. Scopes: write_products, write_cart_transforms.

PieceWhere
The bundle model: the variant it's sold as, its components and a discount; one bundle per variant.api/models/bundle/
Saving publishes the bundle to its variant's $app:bundles.components metafield; deleting takes it down.api/lib/bundles.ts
The cart transform function reads each line's variant metafield in its input query and answers lineExpand for each bundle.extensions/bundler/
Install and reinstall run cartTransformCreate with the function's handle, so it runs on the store's carts.shopifyShop/actions/install.ts, reinstall.ts
The admin page finds the bundle product and what's in it with a search of the store's products; its routes check the session token.web/app/bundles.tsx, web/app/api/
Tests of the actions, and of the function's own code on sample carts.tests/bundler.test.ts
// extensions/bundler/src/cart_transform_run.js: a bundle line becomes its components at checkout.
export function cartTransformRun(input) {
  const operations = [];
  for (const line of input.cart.lines) {
    const bundle = line.merchandise.bundle?.jsonValue; // $app:bundles.components on the variant
    const items = (bundle?.components ?? []).map((c) => ({ merchandiseId: c.variantId, quantity: c.quantity }));
    if (!items.length) continue;
    operations.push({ lineExpand: { cartLineId: line.id, expandedCartItems: items, price: { percentageDecrease: { value: bundle.discountPercent } } } });
  }
  return { operations };
}

Parcel globe (a Shop Mini)

A Shop Mini runs inside the Shop app and brings its own backend. This one is a web app on Apps: buyers paste a tracking number and watch the parcel cross a 3D globe, hub by hub. It shows a bearer token from another platform turned into a session, a role that scopes every read to one user, courier and geocoder calls kept out of requests, an hourly schedule, and CORS for a page that lives at localhost on any port. The Mini itself (React, Tailwind, three and @react-three/fiber, all on Shop's approved list) is in the example's mini/ folder.

PieceWhere
The Mini sends Shop's temporary token for its user as a bearer token. buyerOf verifies it with the Shop Minis Admin API once (userTokenVerify, with the Mini's own key in SHOP_MINIS_API_KEY), keeps the answer in miniSession under the token's hash until Shop says it expires, and knows the buyer by the publicId Shop answers.web/lib/mini.ts, api/models/miniBuyer/, miniSession/
The mini-buyers role: a buyer reads and deletes only shipments whose buyer is their session's userId, and create sets the buyer from the session, whatever the request named.accessControl/permissions.ib.ts, shipment/actions/create.ts
A new shipment answers pending and queues refresh: Ship24's first answer for a number can take a minute, so no request waits on it. The refresh places each new event with a geocoder, pacing calls to one a second, and keeps every place in place, so a hub seen on any parcel before costs nothing.shipment/actions/refresh.ts, api/lib/ship24.ts, api/lib/geocode.ts
The hourly schedule queues a refresh for every parcel not delivered and not checked in the last 50 minutes, one courier call at a time, and removes expired sessions.settings.ib.ts, api/actions/refreshShipments.ts
cors: { origins: ["http://localhost:*"], paths: ["/api/mini/*"] }: Minis call their backend from localhost on a port of their own, in production too.settings.ib.ts
The globe: land as instanced dots sampled from a Natural Earth bitmap generated into land.ts, each route a tube that draws itself with a shader, a glowing head, and drag, spin and turn-to-face in a few lines rather than a package Shop doesn't allow.mini/src/globe/
Tests of the actions, the schedule and the session check, with Ship24, Nominatim and the Admin API stubbed.tests/parcel-globe.test.ts
// web/lib/mini.ts (excerpt): the Mini's bearer token is a temporary Shop token. Shop is asked about it once.
const hash = tokenHash(token);
const session = await api.miniSession.findFirst({ filter: { tokenHash: { equals: hash } }, select: { expiresAt: true, buyer: { select: { id: true, publicId: true, userState: true } } } });
if (session && new Date(session.expiresAt).getTime() > Date.now()) return buyerFrom(session.buyer);

const v = await verifyShopToken(token); // userTokenVerify, with the Mini's API key
if (!v) return null;
const { record: buyer } = await api.miniBuyer.upsert({ on: "publicId", values: { publicId: v.publicId, userState: v.userState, lastSeenAt: now } });
await api.miniSession.create({ buyer: buyer.id, tokenHash: hash, expiresAt: v.expiresAt ?? inFifteenMinutes });

Setup after loading: set SHOP_MINIS_API_KEY (written to the Mini's .env by npx shop-minis setup) and SHIP24_API_KEY on Settings → Environment variables; in mini/, put the app's address in src/config.ts and in manifest.json's trusted_domains, then npx shop-minis dev.

Each example's README has the setup and what to check.

For the patterns these use, see Storefront blocks and app proxy and Embedded admin pages.