App UI: Polaris and App Bridge

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page covers the user interface of an embedded Shopify app: the pages that merchants see inside the Shopify admin. It was written against Polaris web components 2.0 (stable on the Shopify CDN since 2026-10-22, @shopify/polaris-types 2.0.0), the 1.1 line that polaris-1.js still serves, the CDN-hosted App Bridge script (app-bridge.js, never versioned in its URL) with @shopify/app-bridge-react 4.2.14 and @shopify/app-bridge-types 0.7.3, and the React Router app template package @shopify/shopify-app-react-router 4.0.0, for Admin API version 2026-10. Every fact was checked on 2026-10-27. App UI is not tied to a Shopify plan: it works the same on every plan, and the merchant-facing admin appearance that Polaris 2.0 targets is rolling out progressively to all stores.

The page sits after Apps Overview and Scaffolding, which creates the project, and before Authentication and Access Scopes, which explains what happens to the ID token that App Bridge attaches to requests. It answers four questions: which libraries draw an app’s pages, how an app loads them and which version it gets, how to call the admin from inside the app, and how to leave the deprecated @shopify/polaris React package behind. The running example is the Pocket Coffee app, whose "Roast dates" page lets staff edit the roast date shown on each single-origin bean variant.

Where app UI lives

A Shopify app can put interface in several places, and since Polaris was rebuilt on web components in 2025 almost all of them use the same element vocabulary (s-* elements). The places differ in who hosts the code and which subset of components and APIs they get.

Surface What it is UI library

App Home, iframe model

The app’s main page in the admin. A web app that you host and Shopify embeds in an iframe. Recommended for most apps and required for public apps and for anything with server-side logic.

Any web framework, Polaris web components, App Bridge APIs and App Bridge web components.

App Home, UI extension model

The main page built as a Preact UI extension that targets admin.app.home.render and is hosted by Shopify. Available for custom-distribution apps only, from API version 2026-07 (announced 2026-06-01). No backend to host.

Preact and Polaris web components only; a focused set of target APIs; compiled bundle limited to 64 KB.

Admin UI extensions

Actions and blocks that appear on product, order and customer pages of the admin.

Preact and a per-surface subset of Polaris web components.

Checkout and customer account UI extensions

Code that runs at defined points of checkout and in customer accounts.

Preact and a per-surface subset of Polaris web components.

POS UI extensions

Tiles, modals and blocks inside the Point of Sale app.

Preact and a per-surface subset of Polaris web components (31 components when they went stable in 2025).

This page concentrates on the first row, the iframe model, because it is the one that needs a script-loading decision and App Bridge. The other surfaces load nothing themselves: Shopify renders the Polaris components for them, so they follow whichever appearance the host provides and have no CDN URL to choose. The Flow, POS and Admin Extensions and Checkout Extensibility pages cover those surfaces.

The layers of an iframe app

Three things cooperate inside an embedded page. The admin itself draws the surrounding chrome. Your app draws its own content in an iframe using Polaris web components. A small script, App Bridge, connects the two: it publishes the shopify global that the app calls, and it renders chrome elements (title bar, navigation, save bar) in the host page on the app’s behalf.

Layers of an embedded app in App Home: the admin frame with App Bridge web components, the app iframe with Polaris web components and the shopify global, the backend and the Admin GraphQL API

The drawing separates the three libraries that the rest of the page treats one by one.

Library What it provides How the app gets it Versioned?

Polaris web components

The visual building blocks drawn inside the iframe: layout, forms, tables, overlays, feedback.

A script tag for polaris-1.js or polaris-2.js on cdn.shopify.com (the template’s AppProvider renders it).

Yes, with semantic versioning since 2026-08-31.

App Bridge APIs

The shopify global: toast, modal, loading, resource picker, intents, ID token, scopes, navigation helpers.

The app-bridge.js script tag on cdn.shopify.com, plus the shopify-api-key meta tag.

No. The URL has no version and always serves the latest.

App Bridge web components

Elements that control the admin outside the iframe: app navigation, title bar, save bar, app window.

The same app-bridge.js script.

No.

Loading the scripts

An embedded page needs the App Bridge script and one Polaris script in its document head, plus a meta tag that tells App Bridge which app is loading. Apps scaffolded with Shopify CLI already have this wired; a hand-built page, or an app on another framework, adds it itself.

<head>
  <meta name="shopify-api-key" content="%SHOPIFY_API_KEY%" />
  <script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
  <script src="https://cdn.shopify.com/shopifycloud/polaris-2.js"></script>
</head>

The rules that go with this snippet:

  • Exactly one Polaris bundle. A page that loads two Polaris scripts throws an error. The usual causes are a stale tag left in a shared layout, a bundler alias, or adding your own tag next to the one that the template’s AppProvider already renders.

  • App Bridge first. Put app-bridge.js before the Polaris script, so that App Bridge can publish the signal about the admin’s appearance before Polaris initialises. The Polaris 2.0 upgrade guide states this order explicitly.

  • The meta tag carries the client ID. shopify-api-key holds the app’s client ID (the API key shown in the Dev Dashboard). The placeholder %SHOPIFY_API_KEY% is replaced by your server when it renders the page. App Bridge cannot authenticate anything without it.

  • Content Security Policy. If the app sets a CSP, script-src must allow https://cdn.shopify.com on every response that serves the embedded HTML shell. The admin also needs permission to frame your app; the template sets a frame-ancestors directive for the shop and the admin domains on document responses.

  • TypeScript types. Install @shopify/polaris-types (types for the s-* elements) and @shopify/app-bridge-types (types for the shopify global) as development dependencies and list both in compilerOptions.types. The Polaris types major version follows the CDN major version, so 2.0 needs @shopify/polaris-types@^2.0.0.

{
  "compilerOptions": {
    "types": ["@shopify/polaris-types", "@shopify/app-bridge-types"]
  }
}

What the React Router template does

The default template (shopify app init with the React Router template, see Apps Overview and Scaffolding) never shows a script tag in its own source. The package @shopify/shopify-app-react-router renders them. Reading the package source of the version installed by the public template at commit 48b841a (2026-10-09, package 3.0.2; the registry now has 4.0.0) shows exactly what it does:

  • The AppProvider component renders the App Bridge script with the client ID as a data attribute, a Polaris script tag whose URL comes from its polarisUrl prop, and then its children. When polarisUrl is omitted the URL defaults to the unversioned https://cdn.shopify.com/shopifycloud/polaris.js, which tracks the 1.x line and never serves 2.x.

  • AppProvider also listens for the shopify:navigate event that App Bridge fires when the merchant clicks an app navigation link, and calls React Router’s navigate with the link’s href. This is how s-app-nav links change routes without a full page reload.

  • The server helper addDocumentResponseHeaders sets a Link header that preconnects to the CDN and preloads the App Bridge and Polaris scripts (using the polarisUrl of the shopifyApp configuration), and a Content-Security-Policy: frame-ancestors header naming the shop and the admin domains (or 'none' for an app that is not embedded).

The template’s parent route shows the result. The route app/routes/app.tsx authenticates the request in its loader, passes the client ID to the browser and wraps all child routes:

import { Outlet, useLoaderData } from "react-router";
import { AppProvider } from "@shopify/shopify-app-react-router/react";
import { authenticate } from "../shopify.server";

export const loader = async ({ request }) => {
  await authenticate.admin(request);
  return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};

export default function App() {
  const { apiKey } = useLoaderData<typeof loader>();

  return (
    <AppProvider apiKey={apiKey}>
      <s-app-nav>
        <s-link href="/app">Home</s-link>
        <s-link href="/app/additional">Additional page</s-link>
      </s-app-nav>
      <Outlet />
    </AppProvider>
  );
}

The template’s tsconfig.json lists @shopify/polaris-types in types, so the s-* elements type-check in JSX. The template’s package.json pins @shopify/polaris-types to 1.0.1 on that commit, which is one reason the next section matters: a project created from it stays on Polaris 1 until someone changes it.

Polaris web components 1.x and 2.0

Polaris web components are Shopify’s design system as native custom elements. They are delivered from the CDN, not bundled with the app, so the visual refinements, accessibility fixes and new components reach merchants without a rebuild. Their history is short and dated:

Date Event

2025-05-21

"Polaris: unified and for the web" announced; unified web components in early access for the admin, checkout and customer accounts. Until then embedded apps were built with the React library.

2025-10-01

Web components stable, together with API version 2025-10 for extensions; 14 new components for App Home since early access, including a new modal.

2026-08-31

The Polaris CDN adopts semantic versioning. First 1.1 release candidate. Channel URLs such as polaris-1.js and pinned URLs such as polaris-1.1.js are introduced.

2026-09-15

Progressive rollout of the redesigned Shopify admin (new frame, navigation and appearance) starts.

2026-09-22

Polaris 1.1 stable: new s-empty-state, s-number and s-progress components, a fontSize property on heading, paragraph and text, visibleMonths on date picker, a supplementalStart slot on page, overlay events that no longer bubble, and many fixes.

2026-09-24

Polaris 2.0 release candidate (polaris-2.0-rc.js); rc.1 followed on 2026-10-01 with table, select and badge styling and performance work.

2026-10-22

Polaris 2.0 stable, the first major release under semantic versioning. @shopify/polaris-types 2.0.0 had been published a day earlier.

The CDN channels

Under semantic versioning a script URL names either a channel that moves forward within one major version, or a pinned release that never changes. The versioning reference lists the options. To confirm the table against the live CDN, each URL was fetched on 2026-10-27 and the response body hashed (the first 12 hex digits of SHA-256 are shown; equal hashes mean identical files).

Script (under cdn.shopify.com/shopifycloud/) Documented meaning HTTP Size (bytes) SHA-256 prefix

polaris-2.js

Newest stable 2.x release (currently 2.0). Moves with each 2.x release.

200

919,721

af6e068300a7

polaris-2.0.js

Polaris 2.0, frozen as released.

200

919,721

af6e068300a7

polaris-2.0-rc.js

Release candidate for 2.0, updated in place during the candidate window.

200

919,705

89dd2052b7b8

polaris-1.js

Newest stable 1.x release (currently 1.1). Moves with each 1.x release.

200

739,944

912455ad0687

polaris-1.1.js

Polaris 1.1, frozen.

200

739,944

912455ad0687

polaris-1.0.js

Polaris 1.0, frozen.

200

504,626

2e432210f94c

polaris.js

Legacy unversioned entry point. Follows the stable 1.x line and never serves 2.x.

200

739,944

912455ad0687

polaris-3.js

Not published.

404

2,770

(error page)

The observed facts match the documentation: polaris.js, polaris-1.js and polaris-1.1.js are the same file today, and so are polaris-2.js and polaris-2.0.js. The first pair will diverge when a 1.2 release ships, and the second when 2.1 does. The 2.x bundle is about 24 percent larger than 1.1, which fits the fact, explained in the next section, that it carries both appearances of the admin. The CDN responses allow cross-origin use (access-control-allow-origin: *) and are cached for 60 seconds with a five-minute stale-while-revalidate window (cache-control: max-age=60, stale-while-revalidate=300), so a channel update reaches merchants within minutes, not hours. That is the reason to pin a release when you need control over exactly when a change appears.

What changes in 2.0

The 2.0 release is about appearance, not about the component API.

  • Two appearances in one bundle. The Shopify admin is being redesigned with a new frame, new navigation and a new visual style. During the rollout some merchants see the new admin and others the current one. The 2.0 bundle carries the styling for both and selects between them at runtime: the admin signals through App Bridge which appearance is active, and the Polaris elements match it as they initialise. There is no API to call, no flag and no per-theme URL. The versioning reference refers to the redesigned appearance as "Admin Next".

  • Polaris 1 never changes appearance. An app that loads polaris-1.js or polaris.js keeps the current look even inside a redesigned admin, because Polaris 1 does not contain the new styling at all. Any part of the UI that still renders @shopify/polaris React components also keeps the current look.

  • Outside the admin the new style is the default. Components rendered outside the Shopify admin (for example in a local test page) use the new visual style, which gives a way to preview it without a store that has the redesign.

  • Adoption is explicit. Shopify does not move polaris.js or polaris-1.js to 2.x. An app opts in by editing the script URL. Security fixes may still be applied to older majors.

  • Built for Shopify deadline. Apps in the Built for Shopify programme have until 2027-05-01 to update their embedded App Home interface to match the admin’s visual style. Using 2.0 (or its release candidate earlier) is the way to do it.

  • Extensions need nothing. Admin UI extensions and App Home UI extensions follow the admin design that each merchant has automatically, with no code change and no CDN URL.

To see how much of the component API moved, the published type packages were installed and compared on 2026-10-27. The tag list is identical between @shopify/polaris-types 1.1.0 and 2.0.0 (62 tag declarations in the type file, the same set), the only changed property in the machine-readable custom-elements manifest is a new loading property on s-thumbnail, and the type file lists 564 icon names in 1.1.0 and 605 in 2.0.0, with no icon removed (the new names are mostly -filled variants such as cart-down-filled and markets-euro-filled). Going from 1.0.1 to 1.1.0 added five tags (s-empty-state, s-number, s-press-button, s-progress, s-scroll-box) and ten icons. In other words, an app that already renders only Polaris web components does not have to change its markup to adopt 2.0; it has to load the new script, update the types and check the parts of its UI that Polaris does not control.

Moving an app to Polaris 2.0

The upgrade guide has three steps.

Step 1: load the new version. How depends on how the app loads Polaris today. An app with its own script tag changes the URL, as in the head snippet above. An app built from the React Router template sets polarisUrl in two places and keeps the two values identical:

// app/routes/app.tsx
<AppProvider
  apiKey={apiKey}
  polarisUrl="https://cdn.shopify.com/shopifycloud/polaris-2.js"
>
  <Outlet />
</AppProvider>
// app/shopify.server.ts
const shopify = shopifyApp({
  // ...
  polarisUrl: 'https://cdn.shopify.com/shopifycloud/polaris-2.js',
});

Both options need @shopify/shopify-app-react-router 2.1.0 or later and both are optional. If the values differ, the browser preloads one bundle (from the Link header) while the page runs another, which wastes bandwidth and can hide which version is really live. Do not add a Polaris script tag of your own next to AppProvider: that loads a second bundle and fails. Then install @shopify/polaris-types@^2.0.0 so the types describe the components that the app renders. To stay on exactly 2.0 when later 2.x releases ship, load polaris-2.0.js and install @shopify/polaris-types@~2.0.0.

Step 2: audit what Polaris does not control. Polaris elements restyle themselves; anything else keeps its old look. The guide asks for these checks:

  • Load exactly one Polaris bundle, after App Bridge, on the version you intend. If AppProvider is used, polarisUrl must still match the server configuration.

  • Replace remaining Polaris React components. A page that mixes them with web components renders different components with different styles once 2.0 is loaded.

  • Find CSS that reads Polaris custom properties or hardcodes values copied from @shopify/polaris-tokens. Those rules stay on the old palette. Replace copied values with your own tokens, or replace the custom component with a Polaris one.

  • Check fonts. The redesigned admin uses a face called ShopifyInter. Code that hardcodes Inter, or sets its own font stack on top of Polaris, keeps the old type. Polaris 2.0 loads the right faces for the active appearance, so font loading that only exists to support Polaris can go. (The public template’s root.tsx still links a stylesheet for Inter on that commit.)

  • Compare hand-built cards, badges, banners and empty states against the restyled Polaris ones.

  • Position fixed-bottom elements above host UI. The redesigned admin floats UI over the bottom of the viewport, such as the Sidekick bar on desktop or navigation controls on mobile web. App Bridge reports the height of that overlay in the CSS custom property --shopify-safe-area-inset-bottom (default 0px) and already pads the body, so only apps that draw their own sticky footers or floating buttons need var(--shopify-safe-area-inset-bottom, 0px).

  • Walk the main flows, including empty and error states.

Step 3: test both appearances. During the rollout a 2.0 app must be correct in the old and the new admin. The old one is any dev store without the redesign. The new one is a dev store created with the "Shopify admin’s new design" feature preview; with the CLI that is shopify store create dev with the --feature-preview new_admin_design flag. Nothing in the app changes between the two: the same bundle follows whichever admin surrounds it.

Which channel to choose

For a production app the documented advice is the stable channel of the major you have tested (polaris-2.js), which gets bug fixes, accessibility work and new components without an edit. Pin an exact release (polaris-2.0.js) only when a release process needs to control the moment a visual change lands, and then schedule the bump, because pinned releases receive no normal fixes. Use the release-candidate URLs only on development and staging. A project created from the template today is on Polaris 1 until polarisUrl is set; given the 2027-05-01 Built for Shopify date, a new app that intends to qualify should set it on day one.

Writing pages with Polaris web components

A Polaris web component is a custom element whose tag starts with s-. It behaves like a native element: attributes and properties configure it, children and named slots fill it, and standard DOM events come out of it. It works in React, Preact, Vue or plain HTML. The App Home component reference groups its components into seven categories (the 2.0 type package declares 62 tags, which includes sub-elements such as s-option and s-table-row, and two tags, s-press-button and s-scroll-box, that the App Home reference does not list):

Category Components

Actions

s-button, s-button-group, s-clickable, s-clickable-chip, s-link, s-menu

Feedback and status indicators

s-badge, s-banner, s-empty-state (1.1), s-progress (1.1), s-spinner

Forms

s-text-field, s-text-area, s-number-field, s-money-field, s-email-field, s-password-field, s-url-field, s-search-field, s-date-field, s-date-picker, s-color-field, s-color-picker, s-select with s-option and s-option-group, s-checkbox, s-switch, s-choice-list with s-choice, s-drop-zone

Layout and structure

s-page, s-section, s-stack, s-grid with s-grid-item, s-box, s-divider, s-query-container, s-table with s-table-header-row, s-table-header, s-table-body, s-table-row, s-table-cell, s-ordered-list, s-unordered-list with s-list-item

Media and visuals

s-avatar, s-icon, s-image, s-thumbnail

Overlays

s-modal, s-popover

Typography and content

s-heading, s-paragraph, s-text, s-number (1.1), s-chip, s-tooltip

There is deliberately no s-card: a standard grouped area is an s-section, and richer cards are patterns.

Page structure

s-page is the outer wrapper of a page. In an embedded app it also coordinates its heading, breadcrumb and actions with the admin’s title bar, so the merchant sees one coherent header. It has a heading and an inlineSize property (small, base or large) and fills named slots:

Slot Use

primary-action

The single main call to action. Must be one s-button with variant="primary".

secondary-actions

More buttons, or a trigger button whose commandFor points at a sibling s-menu for overflow actions.

breadcrumb-actions

One s-link (or more) that leads back to the parent page.

aside

Content in a side column (use an s-section with slot="aside").

supplemental-start

Content placed before the main content, added in 1.1.

The Polaris page has no slot for status next to the heading, so there is no equivalent of the old Polaris React titleMetadata; a status that matters goes into the page content, for example an s-badge inside a section.

Inside the page, s-section groups content with an optional heading, s-stack lays children out in a row or column (direction="inline" or "block", with a gap), s-grid builds columns, and s-box is the escape hatch with padding, border and background properties. Component properties use camelCase in JSX and in the DOM (accessibilityLabel, commandFor, gridTemplateColumns), while slot names are kebab-case (slot="primary-action"). Links and buttons use href, not the old React url.

Events, commands and forms

Polaris components emit ordinary DOM events, so frameworks bind them the usual way (onClick, onInput, onChange in React and Preact; addEventListener or lowercase attributes in plain HTML).

  • input fires on every keystroke and suits live validation or counters. change fires when the value is committed: on blur or Enter for text fields, immediately for checkboxes and radio-style choices, and after each step for numeric controls. Read the value from event.currentTarget.value, and checkbox or switch state from event.currentTarget.checked.

  • Commands connect a control to an overlay without JavaScript. Give the target an id, and give the button commandFor="<id>" plus a command: --show, --hide, --toggle, --auto (the default, the most appropriate action for the target) or --copy (copy a clipboard item). Modals and popovers do not need a controlled open state; for imperative control, call showOverlay(), hideOverlay() or toggleOverlay() on an s-modal through a ref, or use shopify.modal.show(id) from App Bridge.

  • Since 1.1 the overlay events show, hide, aftershow, afterhide and aftertoggle no longer bubble. Listen on the overlay element itself or use its on* properties.

  • Accessibility is built in (semantic markup, keyboard support, focus management, ARIA), and elements log a console warning when a required accessibility property is missing. Always set label and error on form fields, use labelAccessibilityVisibility to hide a label visually but keep it for assistive technology, and set accessibilityRole where the default role is wrong.

  • Forms are native form elements. Give every field a name. For unsaved-change handling, add data-save-bar to the form (see App Bridge below).

  • When React renders controlled Polaris fields, use React 19. React 18 does not provide the property and event handling for custom elements that controlled fields rely on. The React reference covers React 19, and Forms and Actions explains controlled inputs and form actions.

Example: the Pocket Coffee "Roast dates" page

The page below edits the roast date of two Pocket Coffee single-origin variants. It uses a page with a breadcrumb, a section with a status badge, a form with the automatic save bar, date fields, a primary button, App Bridge’s loading indicator and toast, and an authenticated fetch to the app’s own backend. The backend route and its token verification belong to Authentication and Access Scopes; here only the browser side is shown.

import {useState} from 'react';

type Roast = {sku: string; origin: string; roastDate: string};

const initial: Roast[] = [
  {sku: 'PC-ETH-WHL-250', origin: 'Ethiopia Yirgacheffe', roastDate: '2026-10-20'},
  {sku: 'PC-COL-ESP-1K', origin: 'Colombia Huila', roastDate: '2026-10-22'},
];

export default function RoastDates() {
  const [rows, setRows] = useState(initial);
  const [saving, setSaving] = useState(false);

  async function save(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSaving(true);
    shopify.loading(true);
    // App Bridge adds the ID token to requests for the app's own origin.
    const response = await fetch('/api/roast-dates', {
      method: 'POST',
      body: JSON.stringify(rows),
    });
    shopify.loading(false);
    setSaving(false);
    shopify.toast.show(response.ok ? 'Roast dates saved' : 'Could not save', {
      isError: !response.ok,
    });
  }

  return (
    <s-page heading="Roast dates">
      <s-link slot="breadcrumb-actions" href="/app">Home</s-link>
      <s-section heading="Single-origin beans">
        <s-badge tone="success">Live</s-badge>
        <form data-save-bar data-discard-confirmation onSubmit={save}>
          <s-stack gap="base">
            {rows.map((row, i) => (
              <s-date-field
                key={row.sku}
                label={`${row.origin} (${row.sku})`}
                name={row.sku}
                value={row.roastDate}
                onChange={(e) => {
                  const value = e.currentTarget.value;
                  setRows(rows.map((r, j) => (j === i ? {...r, roastDate: value} : r)));
                }}
              />
            ))}
            <s-button type="submit" variant="primary" loading={saving}>Save</s-button>
          </s-stack>
        </form>
      </s-section>
    </s-page>
  );
}

Source: Page, Save bar and Toast API

The status badge sits inside the section because the page has no slot for it. This file was type-checked, not rendered. On 2026-10-27 it passed tsc 5.9.3 in strict mode with jsx: react-jsx, @types/react 19.3.0, @shopify/polaris-types 2.0.0 and @shopify/app-bridge-types 0.7.3 (tsconfig types set to react, @shopify/polaris-types, @shopify/app-bridge-types). The same project rejected a deliberately wrong file:

src/bad.tsx(4,17): error TS2322: Type '"happy"' is not assignable to type '"auto" | "neutral" | "critical" | undefined'.
src/bad.tsx(5,7): error TS2339: Property 's-card' does not exist on type 'JSX.IntrinsicElements'.
src/bad.tsx(6,48): error TS2322: ... Property 'colour' does not exist on type ... Did you mean 'color'?

The three errors show what the type package buys: an invented tone value, the non-existent s-card, and a misspelled property all fail at build time. Rendering the page needs a development store and shopify app dev, which require a Shopify login and were not run for this documentation.

A page without a framework is the same markup with a script tag (the component tags are real elements, so no build step is needed):

<head>
  <meta name="shopify-api-key" content="%SHOPIFY_API_KEY%" />
  <script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
  <script src="https://cdn.shopify.com/shopifycloud/polaris-2.js"></script>
</head>
<body>
  <s-page heading="Roast dates">
    <s-button slot="primary-action" variant="primary" commandFor="help" command="--show">Help</s-button>
    <s-section heading="Single-origin beans">
      <s-paragraph>Roast dates appear as a badge on the product page.</s-paragraph>
    </s-section>
    <s-modal id="help" heading="Roast dates">
      <s-paragraph>The date is stored as a product metafield.</s-paragraph>
      <s-button slot="primary-action" variant="primary" commandFor="help" command="--hide">Done</s-button>
    </s-modal>
  </s-page>
</body>

Source: Modal

Patterns

The App Home reference recommends starting from patterns, pre-built combinations of components and APIs that also help an app meet Built for Shopify design requirements. Templates are whole-page layouts: homepage, resource index, details and settings. Compositions are smaller assemblies to place inside a template: account connection, app card, callout card, footer help, index table, interstitial navigation, media card, metrics card, resource list and setup guide. The Pocket Coffee app would use the settings template for its roast-date defaults, the index table composition for a list of variants, and the setup guide for onboarding.

Styling limits

Polaris elements use shadow DOM, so app CSS cannot reach inside them, cannot target internal classes, and cannot replace their styles with .Polaris-* selectors from the old React library. App CSS can still style the native elements and wrappers that the app renders itself (a scrolling region, a sticky footer, an ellipsis) and can size or position a component host where ordinary layout rules apply. Use the documented component properties for the look of the component and keep custom CSS scoped to app-owned elements around it.

App Bridge

App Bridge is the connection between the iframe and the admin. The CDN script defines a shopify object on the global scope, and registers custom elements that draw admin chrome. Because the script is loaded from the CDN with no version in the URL, it always serves the latest release, and the App Home reference states that App Bridge APIs and web components are identical in every Polaris reference version. Built for Shopify has required the latest App Bridge (the app-bridge.js script tag in the head of every document) since July 2025.

Configuration

Some shopify.config values are set by the host (shop, the permanent myshopify.com domain; host; locale, the language of the admin user). Others are set with meta tags in the head:

Setting Meaning

shopify-api-key

Client ID of the app. Required.

shopify-disabled-features

Comma-separated feature names to switch off. Supported values: fetch disables the automatic authentication of fetch requests, and auto-redirect disables the redirect that happens when the app is opened outside the admin.

appOrigins

Allow-list of extra origins (format scheme://hostname[:port]) to which authenticated fetch requests may go. Requests to the app’s own origin and its subdomains are always allowed.

debug

{webVitals: true} logs Core Web Vitals to the console during development.

Source: Config API

The shopify global and its APIs

The documentation groups the APIs into three families.

Family API What it does

Authentication and data

App API

Information about the app (ID, settings, metadata).

Config API

Synchronous configuration values (shop, locale, API key, disabled features, allowed origins).

Environment API

Booleans embedded, mobile, pos and intent to adapt to the host, and the safe-area CSS variable.

ID Token API

shopify.idToken() returns an OpenID Connect ID token as a JWT string.

Scopes API

query, request and revoke access scopes at runtime, for optional scopes.

User API

Name, email and account access level of the logged-in staff member (different shape in POS).

Resource Fetching API

Authenticated fetch() to the app backend and to the Admin GraphQL API.

User interface and interactions

Intents API

Launch native admin workflows (create or edit a product, collection, discount and others) and await the result.

Navigation API

Standard anchors and the History or Navigation APIs; shopify://admin/... links reach admin pages.

Picker API

Selection lists for app-specific data.

Resource Picker API

shopify.resourcePicker() to choose products, variants or collections.

Loading API

shopify.loading(true) shows the loading bar in the admin header and shopify.loading(false) hides it.

Modal API

shopify.modal.show(id), hide(id) and toggle(id) for an s-modal.

Save Bar API

Programmatic save bar with shopify.saveBar.

Toast API

shopify.toast.show(message, options) and hide(id).

Reviews API

Request an app-review modal.

Support API

Register a handler for support requests.

Tools API

Register functions that the platform may invoke on the app’s behalf.

Device and platform integration

POS API

Detect and use the POS environment.

Print API

Standard print dialog for documents and labels.

Scanner API

Cameras and barcode scanners.

Share API

Native share sheet on mobile.

Web Vitals API

Report performance metrics.

ID tokens and authenticated fetch

Calls from the iframe to the app’s backend cannot rely on cookies (an embedded app has to work without third-party cookies). App Bridge replaces them with a signed ID token. Older documentation calls it a session token; the current name is ID token. When the app calls the global fetch() for a URL on its own origin or a subdomain, App Bridge’s fetch interceptor adds the token in the Authorization header and the admin user’s locale in Accept-Language, so usually the app never touches the token. Call shopify.idToken() only when a standard fetch is not involved, for example to authenticate a WebSocket handshake.

// Authenticated request to the app backend (the interceptor adds Authorization).
const response = await fetch('/api/roast-dates');
const rows = await response.json();

// Token for something that is not fetch(), such as a WebSocket to your own backend.
const token = await shopify.idToken();
const socket = new WebSocket(`wss://pocket-coffee.example.com/ws?token=${token}`);

Send the token only to your own backend. Verifying its signature needs the app’s client secret, so a third-party service cannot check it without a credential that would let it mint tokens your app accepts. The server side of the exchange (verifying the token, trading it for an access token) is the subject of Authentication and Access Scopes.

Direct API access to the Admin GraphQL API

An iframe app can query the Admin GraphQL API straight from the browser, without a backend route, using the URL scheme shopify:admin/api/graphql.json with the standard fetch(). App Bridge routes the request through the host frame and authenticates it. It must be enabled in shopify.app.toml:

[access_scopes]
scopes = "read_products,write_products"

[access.admin]
embedded_app_direct_api_access = true
direct_api_mode = "online"

direct_api_mode is online (requests use the online access token of the logged-in staff member) or offline (requests use the offline access token); embedded_app_direct_api_access is false when omitted. On 2026-10-27 Shopify CLI 5.0.0 accepted this file in shopify app build --no-input with the placeholder client ID 00000000000000000000000000000000 and rejected direct_api_mode = "sometimes" with "Invalid literal value". The requested scopes still decide what the queries may read.

The Pocket Coffee page can then read the roast date metafield of every Ethiopia variant in the browser:

const response = await fetch('shopify:admin/api/graphql.json', {
  method: 'POST',
  body: JSON.stringify({
    query: `#graphql
      query PocketCoffeeRoastDates($first: Int!) {
        productVariants(first: $first, query: "sku:PC-ETH-*") {
          nodes {
            id
            sku
            title
            product {
              id
              title
              metafield(namespace: "pocket_coffee", key: "roast_date") {
                value
              }
            }
          }
        }
      }`,
    variables: {first: 20},
  }),
});
const {data} = await response.json();

The query document was validated offline against the Admin GraphQL schema for 2026-10 (introspection result downloaded anonymously, 3,708 types) with graphql 16.14.2: it is valid, while a copy with metafeild instead of metafield is reported as Cannot query field "metafeild" on type "ProductVariant". Did you mean "metafield" or "metafields"?. It was not sent to a store. To pin the API version, put it in the URL (shopify:admin/api/2026-10/graphql.json); without it the default is used. Cost-based throttling, pagination and version rules are the subject of Admin GraphQL API. Use direct access for reads and small mutations that belong with the interface; keep long-running or secret-dependent work on the backend.

Feedback, pickers and workflows

  • Toast. shopify.toast.show(message, {duration, action, onAction, onDismiss, isError}) shows a short message at the top of the web admin (the bottom in the mobile app) and returns an ID. It hides itself after duration milliseconds; shopify.toast.hide(id) closes it earlier.

  • Loading. shopify.loading(true) shows the loading bar in the admin header and shopify.loading(false) removes it. The bar stays until stopped, so call both around every asynchronous operation, including the error path.

  • Modal. A Polaris s-modal is the normal dialog. shopify.modal.show('id'), hide and toggle open and close it from script. App Bridge’s s-app-window opens a route of the app in a full-screen overlay for large workflows.

  • Resource picker. await shopify.resourcePicker({type: 'product'}) opens the admin’s own chooser and resolves to the selected resources (GraphQL IDs, titles, handles, and images and variants where relevant). Options include type (product, variant or collection), action (add or select), multiple (boolean or a maximum number), filter (archived, draft, hidden, variants, a hidden query), query and selectionIds to preselect items.

  • Intents. await shopify.intents.invoke('create:shopify/Collection') launches the standard admin form for creating a collection and resolves with the outcome, which removes the need for a custom form. The string form is action:type,value; the object form takes action, type, value and data.

  • Scopes. shopify.scopes.query() returns the granted, required and optional scopes; request([...]) opens the permission modal for optional scopes and resolves to granted-all or declined-all; revoke([...]) removes optional scopes. Required scopes cannot be revoked.

  • Navigation. Plain anchors work: a relative href stays in the app, target="_top" leaves the iframe, and shopify://admin/products or shopify://admin/orders/456 reaches admin pages. history.pushState and the browser Navigation API change the top-level URL.

// Let staff pick which beans get a roast-date badge.
const selected = await shopify.resourcePicker({
  type: 'product',
  multiple: 5,
  filter: {draft: false, archived: false},
});

// Open the native admin page for a product.
open('shopify://admin/products/123', '_top');

// Ask for an optional scope only when the feature needs it.
const result = await shopify.scopes.request(['write_metaobjects']);
if (result.result === 'declined-all') {
  shopify.toast.show('Metaobject access was declined', {isError: true});
}

App Bridge web components

These elements render in the admin, outside the iframe, and need only the App Bridge script.

  • s-app-nav defines the app’s navigation: left sidebar on desktop, a dropdown from the title bar on mobile. Children are s-link elements with relative href values and short noun labels. The app name in the sidebar already links to the app’s home route (default /), so no home link is needed. If the home page is mounted elsewhere, for example at /app, add <s-link href="/app" rel="home">Home</s-link>, which designates it as home and hides it from the rendered menu. Navigation cannot be nested: items are one level deep.

  • Title bar. s-page with heading, and the primary-action, secondary-actions and breadcrumb-actions slots, configures the admin’s native title bar. Use one primary action per page and group the rest in an s-menu. The App Bridge title-bar reference also shows an accessory slot holding an s-badge, but neither the Polaris page reference nor the migration guide lists it, so do not rely on it. In Remix apps, anchor tags are not supported in the title bar, so use Remix’s own link component there.

  • Save bar. Adding data-save-bar to a native form makes the admin show a save bar as soon as any field differs from its initial value, and hide it again on submit or reset. data-discard-confirmation asks for confirmation before a discard. The form’s own onsubmit runs for Save (call event.preventDefault() for custom async saving) and onreset for Discard. Choose either the attribute or the programmatic shopify.saveBar API on a page, never both. A save bar for an s-app-window must come from a form inside the window: a ui-save-bar on the parent page belongs to the parent and appears behind the window.

  • s-app-window shows a route of the app (src) in a full-screen modal window for multi-step workflows. For ordinary dialogs, use s-modal, which decides by itself whether to render inside the app or as a full-screen overlay.

<s-app-nav>
  <s-link href="/app" rel="home">Home</s-link>
  <s-link href="/app/roast-dates">Roast dates</s-link>
  <s-link href="/app/settings">Settings</s-link>
</s-app-nav>

Source: App nav, Title bar and Save bar

App Bridge for React

@shopify/app-bridge-react 4.2.14 (peer dependencies React and React DOM, any version) wraps the same features for React. Its entry point exports the components Modal, NavMenu, TitleBar and SaveBar, the hook useAppBridge, and re-exports the types of @shopify/app-bridge-types. The hook returns the shopify global, or a safe proxy during server-side rendering where the global does not exist, which makes it the right way to call App Bridge from a component that is also rendered on the server (as in React Router):

import {useAppBridge} from '@shopify/app-bridge-react';

export function RoastBadgeButton() {
  const shopify = useAppBridge();

  return (
    <s-button onClick={() => shopify.toast.show('Roast date badge enabled')}>
      Enable badge
    </s-button>
  );
}

Source: useAppBridge

The current template does not need the React wrappers for navigation or the title bar; it uses s-app-nav and s-page directly, and only imports useAppBridge for toasts and intents. The npm package @shopify/app-bridge 3.7.12 belongs to the earlier generation (versions 3 and below, with createApp and action creators) and is not what current docs or the template use; the CDN script is the supported entry point.

The deprecated Polaris React package

@shopify/polaris is the React component library that built every embedded app until 2025. Its last release is 13.9.5, published on 2025-03-26. The npm package now carries a deprecation notice that says Polaris React is deprecated and no longer maintained and points to the Polaris web components documentation; the old documentation is archived at https://shopify.github.io/polaris-react-archive/. Shopify also removed outdated Polaris reference docs on 2026-04-01. For an app this means:

  • It still installs and runs, and it keeps its current appearance indefinitely, but it receives no fixes, no new components, and none of the Polaris 2.0 appearance. A page that uses React Polaris components next to web components renders the two in different styles.

  • New work should use web components. Public apps aiming at Built for Shopify have until 2027-05-01 to match the new admin style, which in practice requires Polaris 2.0 and the removal of React Polaris from embedded routes.

  • Polaris React and web components can coexist while migrating: the end state is an interface that is entirely web components, but @shopify/polaris can stay for a route or dependency that still needs it.

Migration plan

Shopify publishes an incremental migration guide for iframe apps. Its sequence:

  1. Requirements. An iframe-based App Home app; React 19 if web components render through React with controlled fields; the current App Bridge script with the shopify global available; an inventory of the @shopify/polaris version, the test framework, the router and any routes that render outside the admin; third-party packages that render Polaris React internally (they block removal); and CSS that targets .Polaris-* classes or copies tokens. The mappings in the guide assume Polaris React 13.9.5, so upgrade older apps first.

  2. Add web components. Check the HTML for an existing Polaris script before adding one (the template already loads it), allow cdn.shopify.com in the CSP, install @shopify/polaris-types and @shopify/app-bridge-types, and render a small s-badge in the embedded app. If it shows as unstyled text, look for a blocked or duplicate script before touching component code.

  3. Pick a slice. Migrate one page, route family or visually neutral shared component at a time, and keep each slice deployable. Within a page the recommended order is page shell (Page, Card, Layout), display components (badge, banner, text, icon, stacks), buttons, links, menus, modals and popovers, form components with their validation, and finally tables, filters, selection and bulk actions as one data-view slice.

  4. Replace the app shell. The old Frame tree provided chrome and hosted Toast, Loading and ContextualSaveBar. App Bridge and the admin now own those.

  5. Update tests, remove the package, verify.

Polaris React integration Replacement

ContextualSaveBar

data-save-bar on a native form, or shopify.saveBar.

Frame and TopBar

Remove. s-page for page context and s-app-nav for navigation.

Loading

shopify.loading(true) and shopify.loading(false).

Navigation

s-app-nav.

Toast

shopify.toast.show(message, options).

AppProvider, ThemeProvider, PortalsManager, ScrollLock, TrapFocus

Remove once the last Polaris React consumer is gone; Polaris overlays handle their own backdrop, scroll lock and focus.

The guide gives a one-to-one or pattern replacement for about a hundred components; a representative selection:

Polaris React Polaris web components

Page

s-page with primary-action, secondary-actions and breadcrumb-actions slots

Card

s-section, or the app card or callout card pattern

Layout, FormLayout, Grid, InlineGrid

s-page slots, s-grid, s-stack

BlockStack, InlineStack, TextContainer

s-stack with direction="block" or direction="inline"

Text

s-text, s-paragraph or s-heading, chosen by semantics

Button, ButtonGroup, Link

s-button, s-button-group, s-link

Badge, Banner, Spinner

s-badge, s-banner, s-spinner

TextField

s-text-field, s-email-field, s-number-field, s-password-field, s-url-field, s-search-field or s-text-area

Select, Checkbox, ChoiceList, RadioButton

s-select, s-checkbox, s-choice-list with s-choice

DatePicker, ColorPicker, DropZone

s-date-picker or s-date-field, s-color-picker or s-color-field, s-drop-zone

DataTable

s-table

IndexTable, ResourceList, IndexFilters

Index table and resource list compositions, resource index template

Modal, Popover, Tooltip

s-modal, s-popover, s-tooltip

Navigation, Breadcrumbs

s-app-nav, s-link in the breadcrumb-actions slot

Tag, Avatar, Thumbnail, Icon, Image

s-chip or s-clickable-chip, s-avatar, s-thumbnail, s-icon, s-image

EmptyState, ProgressBar

s-empty-state, s-progress (both new in 1.1, so Polaris 1.0 pins must move to polaris-1.js first)

Autocomplete, Combobox, Listbox, Tabs, RangeSlider, Collapsible, Bleed

No direct equivalent: keep the React component during migration, use a native element (details, a range input), route navigation, or build an accessible custom control

SkeletonPage, SkeletonBodyText and the other skeletons

s-spinner loading state; there is no skeleton equivalent

A few translation habits cover most call sites. Replace url with href. Use camelCase properties and kebab-case slot names. Read values from event.currentTarget.value (or checked). Connect buttons to overlays with commandFor and command. Replace imported React icons with the documented string name on s-icon or an icon property, checking the icon reference instead of guessing names. Do not rewrite a whole app in one change: migrate one slice, review the diff and test it in a development store before starting the next. Shopify’s AI Toolkit (and the Dev MCP server) can give a coding agent the current Polaris, App Bridge and pattern documentation for this work, but the guide’s advice stands: one route or self-contained feature at a time.

Updating tests

Unit-test environments such as jsdom do not load the CDN script, so s-* elements stay as un-upgraded custom-element hosts without a shadow DOM. This was confirmed on 2026-10-27 with jsdom 26.1.0: after parsing <s-button variant="primary">Save</s-button>, customElements.get('s-button') is undefined, shadowRoot is null, the variant attribute and the text are readable, and the element’s constructor is plain HTMLElement. Tests should therefore query the rendered custom-element tag and its attributes or properties, not roles or text that only exist in the shadow DOM; dispatch real DOM events instead of calling a React prop; mock window.shopify methods used by the slice; stub showOverlay, hideOverlay and toggleOverlay when a test controls an s-modal; and provide complete ResizeObserver and matchMedia mocks if the environment needs them. For UI extensions on API version 2026-04 and later Shopify ships @shopify/ui-extensions-tester (version 2026.10.0 on npm), which renders an extension in a standard DOM against typed mocks of the shopify global without a running host.

Removing the package for good

After the last slice and all dependent packages are migrated: delete imports of @shopify/polaris and @shopify/polaris-icons; remove AppProvider, Frame, PolarisTestProvider and the Polaris React stylesheet; remove packages that only supported it; delete .Polaris-* selectors and token overrides; remove the dependency and reinstall; and add a lint rule so it does not return. If a public, authentication or standalone route outside the admin still needs Polaris React, keep the dependency and scope the restriction to the embedded routes. An ESLint rule does that:

// eslint.config.mjs
export default [
  {
    files: ['app/**/*.{js,jsx,mjs}'],
    languageOptions: {ecmaVersion: 2022, sourceType: 'module', parserOptions: {ecmaFeatures: {jsx: true}}},
    rules: {
      'no-restricted-imports': ['error', {
        patterns: [{
          group: ['@shopify/polaris', '@shopify/polaris/*', '@shopify/polaris-icons'],
          message: 'Use Polaris web components (s-* elements) in embedded routes.',
        }],
      }],
    },
  },
];

Run with ESLint 9.39.5 on 2026-10-27 against a file importing Card and Page from @shopify/polaris and an icon from @shopify/polaris-icons, it reported two no-restricted-imports errors with the message above and exit code 1, and a file without those imports passed. For TypeScript projects the same rule goes in the @typescript-eslint configuration with the same options. Finish with a search of the tree for @shopify/polaris, @shopify/polaris-icons and .Polaris-.

App Home as a UI extension

For a custom-distribution app that needs no backend, the App Home page can itself be an extension. shopify app init with the extension-only option scaffolds an extensions/app-home/ folder whose configuration targets admin.app.home.render:

api_version = "2026-07"

[[extensions]]
name = "t:name"
handle = "app-home"
type = "ui_extension"

  [[extensions.targeting]]
  module = "./src/AppHome.jsx"
  target = "admin.app.home.render"

The module renders with Preact (render(<App />, document.body) in the default export) and may use client-side routing with preact-iso. It uses Polaris web components, shopify.query() for the Admin GraphQL API, pickers, intents, standard navigation and fetch(). Limits: custom distribution only, Preact only, a subset of browser APIs and a 64 KB bundle. The migration is one-directional: an extension can be reauthored as an iframe app if it outgrows the runtime, but not the reverse. Because the extension follows the admin design automatically, Polaris channel decisions do not apply to it. The same runtime rules apply to the other UI extension surfaces: the CLI refuses to deploy an extension on an API version older than 12 months, reference documentation is kept for the last four stable versions, and since 2026-10-01 apps whose extensions are on API version 2025-07 or earlier cannot be updated. All API versions from 2025-10 use Polaris web components by default.

Common pitfalls

Symptom Cause and fix

Console error about Polaris loading twice

Two Polaris scripts on the page: a stale tag, a layout, or your own tag next to AppProvider. Keep exactly one.

App stays on the old look after the upgrade

polarisUrl still unset (defaults to polaris.js, which never serves 2.x), or only one of AppProvider and shopifyApp was changed. Set both to the same polaris-2.js URL.

Browser downloads a Polaris file that never runs

polarisUrl differs between the Link preload header and AppProvider.

s-badge renders as plain text

The Polaris script is blocked (CSP script-src missing cdn.shopify.com) or duplicated.

Types complain about a component or property that exists at runtime

@shopify/polaris-types major does not match the script (for example polaris-2.js with types 1.x).

Controlled fields lose values or events

React 18 with Polaris web components. Use React 19, or uncontrolled fields.

CSS overrides have no effect

Shadow DOM. Use documented properties; scope custom CSS to your own elements.

Part of the page looks old after 2.0

It is still Polaris React, custom CSS built on token values, hardcoded Inter, or a hand-built card.

Floating button hidden behind the Sidekick bar

Position it with var(--shopify-safe-area-inset-bottom, 0px).

Save bar behind a window, or two save bars

ui-save-bar on the parent page used for an s-app-window, or data-save-bar mixed with shopify.saveBar.

fetch to a third-party API fails or carries a Shopify token

Interception applies to the app’s origin and appOrigins only. Disable it with shopify-disabled-features if the app authenticates itself, and never send the ID token to another service.

Overlay listeners stop firing

Since 1.1 the overlay events do not bubble. Attach them to the overlay element.

Modal action buttons do nothing

commandFor must be the id of the target and command one of the documented values.

Unit tests find no roles inside components

jsdom does not run the CDN script, so there is no shadow DOM. Test tags, attributes and events.

Anchors break in the title bar of a Remix app

Use Remix’s link component, not <a>.

A page needs Polaris 1.0 pin but uses s-empty-state

That component arrived in 1.1. Move to polaris-1.js or polaris-1.1.js and match the types.

What was run and what was not

These parts were executed on 2026-10-27 in a scratch project with no Shopify account and no credentials: the type-check of the Roast dates page and the negative file (TypeScript 5.9.3), the comparison of @shopify/polaris-types 1.0.1, 1.1.0 and 2.0.0, the requests to the CDN script URLs (status, size and hash) and its response headers, the offline validation of shopify.app.toml with [access.admin] by Shopify CLI 5.0.0, the offline validation of the GraphQL document against the 2026-10 Admin schema, the jsdom demonstration, and the ESLint rule. The package source of @shopify/shopify-app-react-router 3.0.2 and the public template were read, not modified.

These parts need a Shopify login or a store and were documented but not executed: rendering any component in a real admin (including how Polaris 2.0 looks in the old and new admin), shopify app dev, every call to the shopify global (toast, loading, resource picker, intents, scopes, ID token, direct API access), the save bar, shopify store create dev with the new-admin feature preview, and the App Home UI extension scaffold. Their behaviour is stated from Shopify’s documentation as of the check date.