Embedded Shopify app UI: the traps that pass every check except the browser
Keith Pillay · 1 October 2026 · 3 min read
The Product Health Scanner is an embedded app: it runs inside an iframe in the Shopify admin, built on Shopify's React Router template with Polaris web components. That combination has a few traps where everything automated stays green and the page is still broken.
These cost me real time, so here they are in the order I'd want to have been warned.
Trap 1: importing a server-only module into component code
Symptom: the page never finishes loading. A spinner runs forever.
Cause: a component imported something from a file named with a .server suffix, even just a constant. The build tool refuses to bundle server-only code into the browser, so the client code never runs and the page never hydrates.
Why it's nasty: typecheck passed, lint passed and unit tests passed. Only loading the real page showed the problem.
What I do now:
- Put shared constants in plain files that both server and client can import.
- After every route change, run a production build and load the page in a browser.
Trap 2: custom-element events in React 18
Symptom: the Polaris table's built-in pager did nothing when clicked.
Cause: React 18 doesn't turn event props on custom elements (like onNextPage) into real event listeners. The handler is never attached.
Some handlers did work, such as onChange on the select component and onClick on some buttons, which is what makes this slippery: it isn't consistent.
What I do now:
- Treat every handler on a custom element as unproven until clicked in the real frame.
- For paging, use normal React Router links with the query string. That worked reliably, and keeps the URL as the source of truth.
Trap 3: projected action buttons
Buttons placed in the primary or secondary action slots get projected into the Shopify admin's outer title bar as well as rendered inline. So there can be two or three look-alike elements for the same button, and a click on the wrong one may not reach the real handler.
If a click appears to do nothing, check at the data layer (a database query or a log line) before deciding the feature is broken. It may only be the click.
Trap 4: automation can't see into the iframe
Because the app's iframe is cross-origin, browser automation couldn't read inside it with the accessibility tree, and some interactions (scrolling below the first screen, focusing a custom text input) simply didn't work.
I found two workarounds worth keeping:
- Drive state through the URL. The search field's value comes from a query parameter, so loading the page with that parameter exercises the same loader and rendering without needing to focus the input.
- Shorten the page. A filter that leaves two or three rows keeps everything above the fold.
And a safety note: a keystroke that lands in the wrong place can leak to the host admin's keyboard shortcuts. Test a single keystroke and look at the result before typing a whole string.
Trap 5: downloads in an iframe
Plain download links don't behave inside the embedded frame. What works: fetch the file (App Bridge adds the session token to fetch), turn the response into a blob, and click a temporary download link.
The common thread
Every one of these passed a type check, a linter and unit tests. The fix is a routine rather than a trick:
- Build and load the page after each route change.
- Click every control in the real frame at least once.
- Verify at the data layer when a UI action seems to do nothing.
- Write down the things automation can't reach, and say so.
I keep all of this in a lessons-learned file that I read before touching the app. It's saved me from repeating a lot of mistakes.
Part of the build story.
