Work in progressThis site is being built in public — expect rough edges, missing screenshots and drops that change under you.

← the log

Add a fullscreen editing surface to your Shopify app with s-app-window

Some screens don't fit in a card. An editor with a canvas, a dozen settings and an image tool needs the whole viewport — and Shopify has a web component for exactly that: <s-app-window>. It opens a fullscreen surface with real admin chrome, loads a route of yours inside it, and hands you a close button for free.

This is a follow-along. By the end you'll have a fullscreen window opening over the admin, loading your own route, talking back to the page that opened it.

If your editing screen fits comfortably in a Polaris Card, you don't need any of this.

Why not <Modal variant="max">? It has a race condition that can leave you with a spinner that never resolves. I moved off it and wrote that up separately — Drop 002: the max-modal postMessage race. Start here instead.

Before you start

  • An embedded Shopify admin app with App Bridge React working — useAppBridge returns on at least one route. → Shopify: build an admin app
  • Polaris installed, with polarisStyles wired into your app's links(). You'll need it again in step 3, and step 3 is where people trip. → Polaris: getting started

What you're building

A parent route (say, a list of items) that opens a fullscreen window onto a child route (the editor for one item).

The two halves talk over BroadcastChannel, not postMessage — they're separate iframes on the same origin, and BroadcastChannel crosses that boundary without caring how deeply nested you are.

Step 1 — Put the element on the parent route

<s-app-window> lives in the DOM the whole time and stays hidden until you show it. It's a web component, so TypeScript needs a nudge:

{/* @ts-expect-error s-app-window is a Shopify web component */}
<s-app-window id="editor-window"></s-app-window>

Step 2 — Point it at a route and open it

Set src at open time. That's how you pass the id of whatever you're editing:

const openEditor = useCallback((itemId: string) => {
  const el = document.getElementById("editor-window") as any;
  el.setAttribute("src", `/app/items/${itemId}`);
  el.show();
}, []);

That's the whole open path. No retries, no readiness check, no waiting.

Step 3 — Move your editor into a real route

This is the actual work of the migration, and it's mostly good news: s-app-window loads a URL, so what used to be modal content becomes an ordinary route with its own loader.

// app/routes/app.items.$id.tsx
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
  const { session } = await authenticate.admin(request);
  return json({ item: await getItem(params.id) });
};

Two things bite here, both worth knowing before you start rather than after:

The child cannot inherit props from the parent. It's a separate document. Anything the parent passed down — permissions, flags, the current user's scopes — the child loader must fetch again itself. This is genuinely cleaner (the route is self-contained), but it is not a copy-paste move.

The child route needs its own Polaris CSS. Make sure polarisStyles is in that route's links(), or wrap it in <AppProvider>. Miss this and every Polaris component renders unstyled, and you will spend ten minutes convinced you broke the app.

Step 4 — Know when it closed

s-app-window fires a native hide event when the user hits ✕. Listen on the element from the parent:

useEffect(() => {
  const el = document.getElementById("editor-window");
  if (!el) return;
  const onHide = () => {
    // autosave, cleanup, clear selection…
  };
  el.addEventListener("hide", onHide);
  return () => el.removeEventListener("hide", onHide);
}, []);

This is more reliable than trying to detect pagehide from inside the child.

Step 5 — Talk back to the parent

The child is in its own iframe, so the parent's React state, its store and its revalidator are all out of reach. Use BroadcastChannel.

Child announces:

const channel = new BroadcastChannel("editor");
channel.postMessage({ type: "item-updated", itemId });

Parent listens and refreshes itself:

const revalidator = useRevalidator();

useEffect(() => {
  const channel = new BroadcastChannel("editor");
  channel.onmessage = (event) => {
    if (event.data?.type === "item-updated") revalidator.revalidate();
  };
  return () => channel.close();
}, [revalidator]);

You're done — here's how you know

Click your open button. The window should fill the viewport with admin chrome around it and a ✕ at top right. Save something inside it, close it, and the parent list should show the new value without a page refresh.

If the list is stale, your BroadcastChannel message isn't landing — see the first gotcha below.

Gotchas

This is the part the docs won't tell you. Everything here cost me time after the thing was working.

  • useRevalidator() in the child revalidates the child. This is the one everyone hits first. The child's revalidator has no idea the parent exists — your list will sit there stale. The parent must call its own revalidator, in response to a message. That's why step 5 is shaped the way it is.
  • <Modal> crashes inside s-app-window. Every nested confirm / delete / reject dialog has to become an inline position: fixed overlay. If your editor has six little dialogs in it, this is the single biggest cost of the move — budget for it up front.
  • Polaris Popover clips inside the window. You may need to hand-roll a position: fixed popover positioned off getBoundingClientRect(), plus your own click-outside and close-on-scroll handlers.
  • sessionStorage is iframe-isolated. localStorage is not. Anything the parent stashed in sessionStorage is simply not there in the child.
  • Your store is a fresh instance. Separate iframe, separate React tree, separate module singleton. Usually fine — the child starts clean from its loader — but it will surprise you once.
  • Escape may close the window out from under you. If a nested overlay needs Escape for itself, stopPropagation().
  • Read state from the store, not from a closure. Close handlers can fire with a cached callback and act on stale values. Reach for useStore.getState() inside the handler.

Docs

The docs cover both elements' happy paths and cover them well. Everything in Gotchas is what's left over.

Or let your AI do it

Paste this into Claude Code, Cursor, or whatever you use. It checks the prerequisites first and sets up anything missing, then scaffolds the wiring — not your editor's actual contents.

It's worth pasting for one reason: the constraints. Ask any AI to "add a fullscreen window to my Shopify app" and it will write the version that hits every gotcha in the list above, because it's reading the same docs that don't mention them.

Scaffold a fullscreen editing surface in my embedded Shopify admin app using the
`<s-app-window>` web component from App Bridge.

Stack: Remix, TypeScript, React, Polaris, App Bridge React. Shopify API version 2026-01.

First, check these prerequisites and set up any that are missing before writing anything
else. Report what you found and what you had to add:

- App Bridge React is installed and `useAppBridge()` works on at least one route.
- Polaris is installed and `polarisStyles` is exported from the app's root `links()`.

Then build exactly this and nothing more:

1. On an existing parent route (I'll tell you which — ask if unclear), render a hidden
   `<s-app-window id="editor-window">` element that stays mounted at all times.
   It's a web component, so add `{/* @ts-expect-error s-app-window is a Shopify web component */}`
   above it.

2. Add an `openEditor(itemId: string)` callback on the parent that sets the element's
   `src` attribute to `/app/items/${itemId}` and then calls `.show()` on it.
   No retry logic. No readiness polling. No setTimeout. The open path is two lines.

3. Create the child route `app/routes/app.items.$id.tsx` with its own loader that calls
   `authenticate.admin(request)` and fetches the item by `params.id`. Stub the fetch and
   the UI — a heading with the item id is enough. Do not invent my data model.

4. On the parent, listen for the element's native `hide` event and leave a commented
   cleanup hook. Do not use `pagehide` from inside the child.

5. Wire `BroadcastChannel("editor")` both ways: the child posts
   `{ type: "item-updated", itemId }`, and the parent listens and calls its own
   `useRevalidator().revalidate()` on receipt. Close the channel on unmount.

Constraints — these are the traps; obey them exactly:

- The child route MUST export its own `links()` including `polarisStyles`, or wrap its
  content in `<AppProvider>`. It does NOT inherit the parent's Polaris CSS.
- The child CANNOT receive props from the parent. Anything it needs — permissions,
  scopes, flags — its own loader must query independently. Do not pass props across.
- Do NOT call `useRevalidator()` in the child expecting the parent to refresh. It
  revalidates the child only. The parent must revalidate itself via BroadcastChannel.
- Do NOT render App Bridge `<Modal>` inside the child. It crashes there. If a nested
  dialog is needed, use a `position: fixed` overlay instead.
- Do NOT use Polaris `<Popover>` inside the child — it clips. Use a `position: fixed`
  element positioned from `getBoundingClientRect()`.
- Do NOT rely on `sessionStorage` to share anything between parent and child; it is
  iframe-isolated. `localStorage` is shared.
- Do NOT assume parent and child share a store instance. They are separate React trees.
- In any close/hide handler, read state via `useStore.getState()` rather than from a
  React closure — closures there can be stale.

Acceptance check: clicking the open button fills the viewport with admin chrome around
my route and a ✕ at top right. Saving in the child and closing it updates the parent
list with no page refresh. Tell me if either fails.

Verified against App Bridge React `s-app-window`, Remix, Chrome 144 — shipped in production since 2026-03.