Embedded admin pages
The app's web/ pages open embedded in the Shopify admin. The page asks App Bridge for a session token, sends it with its requests, and the app's routes check it before trusting the store.
App Bridge
The Shopify app's URL is the environment's address, so the admin loads web/app/page.tsx in an iframe. The starter's layout loads App Bridge with the environment's client ID, which Inference Bureau sets as NEXT_PUBLIC_SHOPIFY_API_KEY:
// web/app/layout.tsx: App Bridge from Shopify's CDN, with the environment's client id.
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta name="shopify-api-key" content={process.env.NEXT_PUBLIC_SHOPIFY_API_KEY} />
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js" />
</head>
<body>{children}</body>
</html>
);
}Shopify also requires embedded pages to say who may frame them, and its app review checks for it. The starter's web/proxy.ts (Next.js's proxy, run before every page) sends Content-Security-Policy: frame-ancestors naming the store's admin (from Shopify's shop parameter, or any *.myshopify.com without it), admin.shopify.com and Inference Bureau's preview. Its matcher leaves out the app proxy's routes, Next.js's assets and /_ib. An app made before the file existed can add it; for a public app, the deploy plan's Review readiness warns while no page sends the header.
Polaris
Choose Polaris under Admin pages on New app (or ib-apps create --platform shopify --polaris, or polaris: true to POST /v1/apps) and the starter's admin pages use Shopify's Polaris web components: <s-page>, <s-section>, <s-button> and the rest, loaded from Shopify's CDN beside App Bridge. Pages then look and behave like the rest of the admin, which Built for Shopify asks of embedded apps. Without it, the starter is in Inference Bureau's look, styled with its own CSS.
// web/app/layout.tsx: App Bridge and Polaris web components from Shopify's CDN.
<head>
<meta name="shopify-api-key" content={process.env.NEXT_PUBLIC_SHOPIFY_API_KEY} />
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js" />
<script src="https://cdn.shopify.com/shopifycloud/polaris-2.js" />
</head>They are still Next.js pages, written in React. web/polaris.d.ts gives TypeScript the components' types from @shopify/polaris-types (a dev dependency). An existing app can switch by adding the script, that file and the package (^2.0.0). polaris-2.js is Polaris 2, which matches the redesigned admin and takes every 2.x release; an app made before it loads polaris.js, which stays on Polaris 1.1 until its script says polaris-2.js and its types ^2.0.0. A public app's Review readiness lists the admin look as a step until the layout loads Polaris.
From the page
Client components import from @inferencebureau/apps-sdk/browser:
| Function | Does |
|---|---|
sessionFetch(input, init) | fetch with the session token as Authorization: Bearer. Use it for every call to the app's own routes. |
shopifySessionToken() | The token itself: the preview's, else App Bridge's shopify.idToken(), else null. It gives up on App Bridge after five seconds, so a page opened outside the admin doesn't hang. |
isPreview() | Whether the page is open in Inference Bureau's preview rather than the Shopify admin. |
previewSession() | The preview session, or null. |
requestScopes(scopes) | Shows Shopify's approval for optional scopes (App Bridge's shopify.scopes.request). True once all are granted; false if the merchant declines, or in the preview. |
"use client";
import { isPreview, sessionFetch } from "@inferencebureau/apps-sdk/browser";
// sessionFetch adds the session token as Authorization: Bearer …
const call = (path: string, init: RequestInit = {}) =>
sessionFetch(path, { ...init, headers: { ...(init.headers ?? {}), "content-type": "application/json" } });
// App Bridge's toast in the admin; the console in the preview, where there's no admin around the page.
const toast = (message: string, isError = false) =>
isPreview() ? console[isError ? "warn" : "info"](message) : window.shopify?.toast?.show(message, { isError });
async function approve(id: string) {
const res = await call(`/api/reviews/${id}/approve`, { method: "POST", body: JSON.stringify({}) });
toast(res.ok ? "Approved" : "Couldn't approve", !res.ok);
}On the server
verifyShopifySession(request) from @inferencebureau/apps-sdk reads the bearer token and asks Inference Bureau to check it, since only Inference Bureau holds the client secret Shopify signs it with. It answers { shop, userId, sessionId, preview?, scopes } (scopes: what the store has granted), or null when the request has no valid token. Never trust a store named any other way, such as a query parameter.
import { verifyShopifySession } from "@inferencebureau/apps-sdk";
export const dynamic = "force-dynamic";
/** GET /api/reviews: the signed-in store's pending reviews. */
export async function GET(request: Request) {
const session = await verifyShopifySession(request);
if (!session) return Response.json({ error: "Open this page from the Shopify admin." }, { status: 401 });
// session.api reaches this store's records only, as the shopify-app-users role.
const page = await session.api.review.findMany({
filter: { status: { equals: "pending" } },
sort: { createdAt: "Ascending" },
});
return Response.json({ shop: session.shop, preview: Boolean(session.preview), reviews: page.records });
}The token is checked as Shopify documents: its signature, expiry and not-before (with ten seconds of leeway), that its audience is this environment's client ID, and that issuer and destination name the same store. The store must also have this environment's app installed; under Shopify managed installation, a store Shopify installed that Inference Bureau hasn't recorded yet is recorded by this check, from the token (Install on a store). Read and write through session.api: it reaches only session.shop's records, as the shopify-app-users role (Access control), while createClient() reaches every store's.
A token that fails the check returns null. Any other failure, such as the app's own environment token being refused, throws ApiError.
Optional scopes
Scopes set on Settings → Access scopes are asked for at install. A feature that needs more only once a merchant uses it can declare those as optional in settings.ib.ts and ask in the admin when the merchant gets there. The route answers scopesRequired(scopes): a 403 naming them, which sessionFetch turns into Shopify's approval, then sends the request again once they're granted. If the merchant declines, the 403 comes back to the page. The next session check records the new grant.
// settings.ib.ts: asked for when a merchant reaches the feature, not at install.
// shopify: { optionalScopes: ["read_orders"] },
// web/app/api/orders/route.ts
import { scopesRequired, verifyShopifySession } from "@inferencebureau/apps-sdk";
export async function GET(request: Request) {
const session = await verifyShopifySession(request);
if (!session) return Response.json({ error: "Open this page from the Shopify admin." }, { status: 401 });
// 403 naming the scopes: sessionFetch shows Shopify's approval and sends the request again once granted.
if (!session.scopes.includes("read_orders")) return scopesRequired(["read_orders"]);
// …
}Preview sessions
The dashboard's Preview shows the admin page without the Shopify admin around it, so there is no App Bridge token. Instead the dashboard hands the page a preview session for a store the environment is installed on: a token of the same shape, signed by Inference Bureau for the team member previewing, valid for one environment and one store for an hour.
- It arrives on the page's first URL as
#ib_preview=…. The browser entry moves it intosessionStoragefor the tab and takes it off the address bar, so the page's own navigation keeps it. sessionFetchsends it, andverifyShopifySessionaccepts it, addingpreview: { actor }(the person previewing,user:<id>).- In the preview
sessionFetchsends the request without App Bridge'sfetchwrapper, which would otherwise wait forever for a token from an admin that isn't there.
Use isPreview() on the page and session.preview on the server to swap admin-only features (toasts, modals, navigation) for something that works without the admin, or to log who previewed.