Storefront blocks and app proxy
Blocks merchants add in the theme editor put the app on the storefront. They render on Shopify's side from metafields the app writes; anything live goes through the app proxy to routes under web/app/proxy/.
Theme app extensions
Each folder under extensions/ is a theme app extension: app blocks and app embeds merchants add in the theme editor. It needs a shopify.extension.toml with type = "theme"; the folder name is its handle (lowercase letters, digits and dashes). An extension with no file in blocks/ isn't released. UI extensions, Functions, Flow, web pixels, Sidekick tools and the other types live in extensions/ too: see Shopify extensions.
# extensions/reviews/shopify.extension.toml
name = "Product reviews"
type = "theme"Extensions are released with the Shopify app's configuration, never by hand: development's after each sync that changes extensions/ (as shopify app dev would), production's when a deploy goes live. Files Shopify doesn't allow in an extension are left out and reported; a release Shopify refuses shows in Problems, and the store keeps the blocks it had.
Data for blocks: metafields
A block renders with the page, on Shopify's servers, so the fastest way to give it data is a metafield the app writes ahead of time. Nothing loads late and no request reaches the app.
| Where | Written to | Read in Liquid as |
|---|---|---|
| On the app's installation (one value per store) | ownerId: currentAppInstallation.id, any namespace | app.metafields.ib_app.block.value |
| On a product, collection or other resource, reserved to the app | Namespace $app:<name> | product.metafields["$app:reviews"].list.value |
Shopify resolves $app: to the app's own namespace, so only this app can write those metafields and the same block works for development's and production's Shopify apps. Writing to a product needs write_products; create metafield definitions in the install action if storefront or other apps need to read them.
// In an action's onSuccess: product metafields in the app-reserved namespace.
await shopify.graphql(
`mutation PublishReviews($metafields: [MetafieldsSetInput!]!) {
metafieldsSet(metafields: $metafields) { userErrors { field message } }
}`,
{ metafields: [
{ ownerId: productGid, namespace: "$app:reviews", key: "list", type: "json", value: JSON.stringify(list) },
{ ownerId: productGid, namespace: "$app:reviews", key: "summary", type: "json", value: JSON.stringify(summary) },
] },
);The app proxy
Every Shopify app gets an app proxy: https://<store>/apps/<subpath>/… on the storefront, where the subpath is the app's slug in production (letters, digits and dashes, at most 30) and the slug with -dev in development (/apps/reviews-dev), so the two never answer for each other on a store that has both. Shopify signs each request and sends it to Inference Bureau, which checks the signature and passes it to the app's routes under web/app/proxy/: /apps/<subpath>/reviews reaches web/app/proxy/reviews/route.ts.
verifyProxyRequest(request) checks Inference Bureau's signature on the forwarded request and answers { shop, customerId, pathPrefix } (customerId is null for a guest), or null for anything that didn't come through the storefront. Return 404 for null, so the route can't be called directly.
import { verifyProxyRequest } from "@inferencebureau/apps-sdk";
/** https://<store>/apps/<subpath>/status: JSON for the storefront's scripts. */
export async function GET(request: Request) {
const proxy = await verifyProxyRequest(request);
if (!proxy) return Response.json({ error: "not found" }, { status: 404 });
return Response.json({ shop: proxy.shop, signedIn: proxy.customerId !== null, at: new Date().toISOString() });
}Every proxied request is a proxy event in Logs with the route's status. In development, a sleeping dev server gets a few seconds to wake while the shopper waits.
Cookies don't pass through the proxy in either direction, and Shopify gives up on slow answers. Keep proxy routes fast, and don't rely on a session of your own: use customerId.
Answering in Liquid
A route that answers with Content-Type: application/liquid is rendered by Shopify inside the store's theme, with its header and footer, so the page looks like the rest of the store. Liquid objects like shop, customer, routes and all_products work in it. Everything else (JSON, HTML) goes back as the route sent it.
import { verifyProxyRequest } from "@inferencebureau/apps-sdk";
/** https://<store>/apps/<subpath>: rendered by Shopify inside the store's theme. */
export async function GET(request: Request) {
const proxy = await verifyProxyRequest(request);
if (!proxy) return new Response("Not found", { status: 404 });
return new Response(
`<div class="page-width">
<h1>Wholesale</h1>
<p>Rendered by the app and Shopify together for {{ shop.name }}${proxy.customerId ? ", signed in as {{ customer.first_name }}" : ""}.</p>
</div>`,
{ headers: { "content-type": "application/liquid" } },
);
}Escape anything the app puts into Liquid that came from a shopper: it is rendered as template code.
Finding the proxy
A merchant can change the subpath in the Shopify admin, so blocks shouldn't hard-code it. Inference Bureau keeps the store's proxy path in an app-owned metafield on the installation, written on install and on each release when it's missing, and replaced when Shopify's proxy requests show a different path:
{%- assign proxy = app.metafields.ib_app.proxy_path.value -%}
<a href="{{ proxy }}/wishlist">Your wishlist</a>Signed-in customers
For what's a customer's own (a wishlist, their reviews, points, order lookups), use verifyCustomerRequest(request). It takes the same signed requests as verifyProxyRequest, from the app proxy or a customer account extension, and answers null for a guest. For a signed-in customer it answers { shop, customerId, customerGid, customer, source, api }:
customeris their syncedshopifyCustomerrecord. A customer who signed up a moment ago, before the sync brought them, is fetched from Shopify and written on the spot (through the model'screateaction). It's null when the app doesn't sync customers or the store hasn't grantedread_customers.apiis held to thestorefront-customersrole, for the store, with automatic tenancy: of models that belong to a customer, it reads and acts on only theirs, and makes new ones as theirs.- Actions it runs see the customer as
ctx.caller:{ role, customerId, customerRecordId }.
import { verifyCustomerRequest } from "@inferencebureau/apps-sdk";
/** GET /apps/<subpath>/wishlist, and the account page's extension: the signed-in customer's wishlist. */
export async function GET(request: Request) {
const me = await verifyCustomerRequest(request);
if (!me) return Response.json({ error: "Sign in to see your wishlist." }, { status: 401 });
const page = await me.api.wishlistItem.findMany(); // theirs only: wishlistItem belongs to shopifyCustomer
return Response.json({ items: page.records }, { headers: { "cache-control": "no-store" } });
}
export async function POST(request: Request) {
const me = await verifyCustomerRequest(request);
if (!me) return Response.json({ error: "Sign in to save products." }, { status: 401 });
const { productGid } = (await request.json()) as { productGid: string };
// Made as theirs: customer and shop are set for you.
return Response.json(await me.api.wishlistItem.create({ productGid }), { status: 201 });
}Customer account extensions
A customer account UI extension runs in a worker in the customer's account, not on the storefront, so it can't use the app proxy. It calls the environment's extension URL instead, shown on Settings → Shopify, with its session token. Inference Bureau checks the token with the Shopify app's client secret (its store, the app it was issued to, and the customer it names, when they're signed in), then passes the request to the app's routes under web/app/extension/ with the same signed headers as the app proxy. verifyCustomerRequest takes it with source: "extension", so one route can serve a storefront page and the account page. Any origin may call the URL (the token is the credential), and each request is a proxy event in Logs from customer-account.
// In the extension (Preact, in the customer's account)
const API = "https://api.inferencebureau.com/v1/apps/shopify/<environment id>/extension";
const token = await shopify.sessionToken.get();
const res = await fetch(`${API}/wishlist`, { headers: { Authorization: `Bearer ${token}` } });
// reaches web/app/extension/wishlist/route.tsThe extension needs network_access = true in its shopify.extension.toml, and Shopify requires that capability to be approved for the app in the Partner Dashboard before its calls go out. Shopify includes the customer only when they're signed in and the app may read customer accounts.