Shopify app notes · 09

App Bridge & Polaris

Building an admin UI that looks like Shopify built it: the components, the modals and toasts, the resource picker, and the behaviours that surprise people coming from React.

App Bridge Polaris web components

Two different things

Polaris web components

The visible UI: buttons, tables, cards, form fields. HTML tags starting with s-.

They match the Shopify admin automatically, including dark mode.

App Bridge

The connection to the admin around your app: toasts, the resource picker, navigation, the session token.

A JavaScript object, not visual.

Not Polaris React

You may find tutorials importing <Button> from @shopify/polaris. That is the older React library. Modern templates use these s- web components instead — they need no npm package and no CSS import.

Do not mix the two. Pick the one your project already uses.


Where they come from

One wrapper in your admin layout switches everything on:

app/routes/app.jsx
<AppProvider embedded apiKey={apiKey}>
  <s-app-nav>
    <s-link href="/app/products">Products</s-link>
  </s-app-nav>
  <Outlet />
</AppProvider>

Every page nested under app.jsx can now use s- tags and App Bridge, with nothing to import.

Trap — components render as nothing on a standalone page

Build a page outside the embedded area — an internal tools page, a public page — and your s- tags produce blank space. The browser does not know those tags, so it renders empty boxes with no error.

Such a page must load the library itself:

<script src="https://cdn.shopify.com/shopifycloud/polaris.js"></script>

Put it in that page's own layout, not in the global root, so it cannot interfere with the embedded pages.


Page structure

Three components give you the standard admin layout:

<s-page heading="Products">
  <s-section heading="All products">
    <s-stack gap="base">
      <s-text>Some content</s-text>
      <s-button variant="primary">Add product</s-button>
    </s-stack>
  </s-section>
</s-page>
  • s-page — the page frame and title.
  • s-section — a white card with an optional heading.
  • s-stack — spacing between children. Use it instead of CSS margins.

s-stack takes direction="inline" for a row, plus gap, justifyContent and alignItems:

<s-stack direction="inline" gap="small" justifyContent="space-between">
Trap — tone and color are not the same

tone carries meaning: info, success, warning, critical, neutral, auto. It does not accept subdued.

To simply mute some text, use color:

<s-text tone="subdued">    // ignored — not a valid tone
<s-text color="subdued">   // correct

Like every invalid token, the wrong one is silently ignored — the text simply is not muted, with no error anywhere.

Token values are fixed words

gap accepts specific names — none, small, base, large and a few numbered variants. Invent a value like gap="12px" and it is silently ignored, leaving elements stuck together with no error.

When spacing looks wrong, first suspect an invalid token. Copy values you have seen working elsewhere in the codebase.


The components you will use most

ComponentForKey attributes
s-buttonActionsvariant, tone, href, disabled
s-textTextcolor="subdued" to mute; tone="critical" for meaning
s-badgeStatus labelstone="info" / "success"
s-bannerMessagestone, dismissible
s-text-fieldText inputlabel, value, error, details
s-selectDropdownlabel, value, with s-option children
s-search-fieldSearch boxvalue, onInput, onClear
s-tableData tables—
s-modalDialogsid, heading, size
s-linkNavigationhref

Buttons follow the admin's own hierarchy — one primary action per section, everything else plain:

<s-button variant="primary">Save</s-button>
<s-button href="/app/products">Cancel</s-button>
<s-button tone="critical" variant="tertiary">Delete</s-button>

A button with href navigates; one with onClick runs code. Use href for links — it works with keyboard and middle-click, which a click handler does not.

Trap — a wrapper that hides your buttons

Grouping wrappers can swallow their children if used in an unexpected place — you end up with buttons that exist in the DOM and are simply not visible.

If a button vanishes, replace the wrapper with a plain <s-stack direction="inline"> and it will come back. Reach for a stack first; add specialised wrappers only once you have seen them work.


Form fields are controlled

These components expect React-style controlled values: hold the value in state and update it on change.

const [title, setTitle] = useState("");

<s-text-field
  label="Title"
  value={title}
  onChange={(e) => setTitle(e.currentTarget.value)}
  error={errors.title}
  details="Shown on the product page"
/>
<s-select label="Status" value={status} onChange={(e) => setStatus(e.currentTarget.value)}>
  <s-option value="active">Active</s-option>
  <s-option value="draft">Draft</s-option>
</s-select>
Trap — these fields do not submit with a form

Covered in Note 07 and worth repeating, because it silently sends empty data: a name attribute on <s-text-field> does not reliably make it part of a native form submission.

Keep values in state and submit them yourself:

<s-button onClick={() => fetcher.submit({ title }, { method: "post" })}>
  Save
</s-button>
onChange vs onInput

Different components emit different events — some fire onChange, some onInput as you type. If typing does nothing, try the other one. Read the value from e.currentTarget.value, which is the reliable one on these components.


Modals, without JavaScript

Modals are declarative. Give the modal an id, then point a button at it:

<s-button commandFor="gallery-modal" command="--show">
  Choose image
</s-button>

<s-modal id="gallery-modal" heading="Select an image" size="large">
  <s-stack gap="base">
    {/* modal content */}
  </s-stack>

  <s-button commandFor="gallery-modal" command="--hide">Close</s-button>
</s-modal>

No open/closed state, no useState, no refs. The button tells the modal what to do.

Render the modal, always

Put the modal in your JSX unconditionally — do not wrap it in {isOpen && ...}. It is hidden until shown. Conditionally rendering it means the id does not exist when the button looks for it.


Toasts

The small confirmation that slides up from the bottom of the admin. It comes from App Bridge, not a component:

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

const shopify = useAppBridge();

shopify.toast.show("Product saved");
shopify.toast.show("Could not save", { isError: true });

Show it after a save finishes:

useEffect(() => {
  if (fetcher.state === "idle" && fetcher.data) {
    shopify.toast.show(fetcher.data.message, { isError: !fetcher.data.success });
  }
}, [fetcher.state, fetcher.data, shopify]);

Toasts are for confirmations. Use s-banner for anything the user needs to read carefully or act on.


The resource picker

App Bridge opens Shopify's own product/collection/customer browser — search, filters and all — for free:

const selected = await shopify.resourcePicker({
  type: "product",
  multiple: false,
  filter: { variants: false, draft: false, archived: false },
});

if (!selected?.length) return;      // user cancelled
const product = selected[0];
product.id;       // "gid://shopify/Product/123"
product.title;

Always handle the cancel case — closing the picker resolves with nothing.

Trap — the picker's data is truncated

The picker returns a preview of the resource, not the complete record. Nested lists such as a product's variants are capped — you may receive only the first few dozen of a product's 300 variants.

On a small test product everything appears; on a real one, options silently go missing, and it looks like a display bug in your own code.

Rule: use the picker to let the user choose, then fetch the full record from the Admin API using the returned id — with pagination (Note 06).


Navigation

Inside the embedded admin, use s-link or React Router's Link with app paths. App Bridge keeps the browser's address bar in sync, so the back button and bookmarks behave.

<s-link href="/app/products">Products</s-link>
<s-button href="/app/products/create" variant="primary">New</s-button>

To link out to the Shopify admin itself — a product's real admin page — use a full URL and open a new tab:

<s-button
  href={`https://admin.shopify.com/store/${shopHandle}/products/${numericId}`}
  target="_blank"
>View in admin</s-button>

Custom elements behave differently from React

These are real browser elements wrapped for React, and two habits do not carry over.

Trap — they are not ready on first render

A custom element is upgraded by the browser once its script has loaded, which may be after your component first renders.

So measuring one, or calling a method on it, inside an early useEffect can hit a plain, empty element. Prefer attributes and props over reaching into the DOM. If you genuinely must, check the element has the method before calling it.

The upside: markup rendered before the script loads is upgraded retroactively, so ordering of the script tag does not matter.

Also: style them with their own attributes, not CSS overrides. Internal structure is not a public API, and a CSS rule targeting their insides can break with any Shopify update. When you need layout that components do not offer, wrap them in your own <div> and style that.


A complete page

export default function Products() {
  const { products } = useLoaderData();
  const fetcher = useFetcher();
  const shopify = useAppBridge();
  const busy = fetcher.state !== "idle";

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data?.message) {
      shopify.toast.show(fetcher.data.message, { isError: !fetcher.data.success });
    }
  }, [fetcher.state, fetcher.data, shopify]);

  return (
    <s-page heading="Products">
      <s-section>
        <s-stack direction="inline" gap="base" justifyContent="space-between">
          <s-text>{products.length} products</s-text>
          <s-button href="/app/products/create" variant="primary">
            Add product
          </s-button>
        </s-stack>
      </s-section>

      <s-section heading="All products">
        {products.length === 0 ? (
          <s-text color="subdued">No products yet.</s-text>
        ) : (
          <s-stack gap="small">
            {products.map((p) => (
              <s-stack key={p.id} direction="inline" justifyContent="space-between">
                <s-text>{p.title}</s-text>
                <s-button
                  tone="critical"
                  variant="tertiary"
                  disabled={busy}
                  onClick={() => fetcher.submit(
                    { intent: "delete", id: String(p.id) },
                    { method: "post" },
                  )}
                >Delete</s-button>
              </s-stack>
            ))}
          </s-stack>
        )}
      </s-section>
    </s-page>
  );
}

Common problems

My components render as blank space

The Polaris script is not loaded on that page — usually a page outside the embedded /app/* area. Add the CDN script to that page's layout.

Typing in a field does nothing

Either you set value without an onChange to update state, or that component uses onInput instead. Try the other event.

My button is not visible

Often a wrapper swallowing it. Replace the wrapper with <s-stack direction="inline">.

The modal never opens

The id and commandFor do not match, or the modal is conditionally rendered so it does not exist yet. Render it unconditionally.

Spacing is ignored

An invalid token such as gap="12px". Use the named values.

The picker returned fewer variants than the product has

Expected — the picker truncates. Fetch the full record from the Admin API using the id it gave you.


Cheat sheet

# layout
<s-page heading> → <s-section heading> → <s-stack gap>

# stack
direction="inline"  gap="base"  justifyContent="space-between"

# buttons
variant="primary"            the one main action
tone="critical"              destructive
href=                        navigates (better than onClick for links)

# fields — controlled, submitted manually
value={x} onChange={(e) => setX(e.currentTarget.value)}
fetcher.submit({ x }, { method: "post" })

# modal — no state needed
<s-button commandFor="id" command="--show">
<s-modal id="id" heading size>           render it always

# App Bridge
shopify.toast.show(msg, { isError })
await shopify.resourcePicker({ type: "product" })   → then fetch the full record

# gotchas
blank page          Polaris script missing outside /app/*
empty form data     components don't submit natively
ignored spacing     invalid gap token
missing variants    picker truncates nested lists