Polaris React to Web Components: Migrate in Slices

Moving an embedded Shopify app from Polaris React to web components is a route-by-route job. Load polaris-1.js from Shopify’s CDN next to the old package, upgrade to React 19, then migrate one page at a time: the Page shell first, then display components, actions, forms and finally tables. Both systems render side by side until the last import goes.

That is the short version of Shopify’s own migration guide, which links a separate guide per component.

Is Polaris React actually deprecated?

Yes. The npm entry for @shopify/polaris now carries a deprecation notice that reads “Polaris React is deprecated and no longer maintained”, and the source has moved to an archived repository, Shopify/polaris-react-archive. The last release is 13.9.5, from March 2025. Shopify released Polaris web components for app development on 1 October 2025, and the migration guide uses 13.9.5 as its comparison baseline.

Nothing breaks today; the package still installs and renders. What stops is maintenance, so any bug you hit in it is yours to work around. If you are on Polaris 12 or older, move to 13.9.5 first, because the per-component guides assume its prop names.

What has to be true before you start?

React 19 comes first. If your app renders the new components through React, as the Shopify app template does, upgrade before you touch any controlled field. Shopify’s guide says React 18 does not provide the custom-element property and event behaviour that controlled Polaris web components rely on, so a field with a value and an onInput handler is exactly what goes wrong on 18. If the upgrade is blocked, leave controlled fields on Polaris React for now.

The app also needs the iframe-based App Home model and the current App Bridge script, so the shopify global exists for toasts, the loading bar and the save bar.

Then search your stylesheets for .Polaris-. Web components render inside a shadow root, so any CSS that reached into Polaris React’s class names stops working, and there is no supported way to restyle a component’s internals. Also list any dependency that renders Polaris React itself. You cannot remove @shopify/polaris while one of those is still in the tree.

How do you add Polaris web components to an existing app?

Apps created by the current Shopify CLI template already load them, because the AppProvider from @shopify/shopify-app-react-router renders the App Bridge and Polaris script tags. For anything older, add the script to the document head, after App Bridge and before your bundle:

<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-1.js"></script>
</head>

Then install the type packages and add them to compilerOptions.types in tsconfig.json. The components themselves need no import; the script registers them globally.

npm install --save-dev @shopify/polaris-types@latest @shopify/app-bridge-types@latest

Per the versioning reference, polaris-1.js is the stable channel (serving 1.1 as of October 2026) and polaris-1.1.js is frozen. Pin if you want to choose when the UI changes, and keep @shopify/polaris-types on the matching range: ^1.1.0 for the channel, ~1.1.0 for the pin. On the React Router template, set polarisUrl on both AppProvider and shopifyApp(), which needs @shopify/shopify-app-react-router 2.1.0 or later.

If you send a Content Security Policy, add https://cdn.shopify.com to script-src in the same deploy. When an s-badge shows up as plain unstyled text, Shopify’s advice is to check the console and network panel for a blocked or duplicate script before touching component code.

What order should a Polaris React to web components migration take?

Pick one route and work outside in: the page shell (Page, Card, Layout), then display components such as Badge and Banner, then buttons, menus and modals, then form fields, and finally tables, filters and bulk actions as a single piece.

Shell first because s-page is what talks to the admin title bar: its heading, breadcrumbs and actions. An s-badge can sit inside an old Polaris React page while you work, but Shopify warns that swapping layout pieces independently gives inconsistent spacing and surfaces, so move Page, Card and Layout together.

The page itself is a small change. Actions stop being prop objects and become slotted buttons:

export function ProductsPage({ onCreateProduct }: { onCreateProduct: () => void }) {
  return (
    <s-page heading="Products">
      <s-button slot="primary-action" variant="primary" onClick={onCreateProduct}>
        Create product
      </s-button>
      <s-section heading="All products">
        <s-paragraph>Manage the products available through your app.</s-paragraph>
      </s-section>
    </s-page>
  );
}

Three rules cover most of what follows. Multi-word properties are camelCase (accessibilityLabel, commandFor), slot names stay kebab-case (primary-action, breadcrumb-actions), and url becomes href. Field values come from event.currentTarget.value rather than the first argument of onChange. Icons stop being imported React components and become a string name on s-icon.

Which components have no direct replacement?

Read the component mapping table before you estimate, because a searchable picker or a tabbed settings screen is where the hours go. There is no s-card. Card becomes s-section or one of the App Home patterns (app card, callout card). There is no s-form either; you use a native form element.

Autocomplete and Combobox have no equivalent. The guide’s advice is to keep them on Polaris React during the migration or build an accessible combobox yourself. Tabs becomes route navigation or a custom tab interface. RangeSlider becomes a native range input. Collapsible becomes details and summary. All five skeleton components collapse to s-spinner, because there is no skeleton loader in the new set.

TextField splits into seven components: s-text-field, s-email-field, s-number-field, s-password-field, s-url-field, s-search-field and s-text-area. Route each call site to the specific one; a number squeezed into s-text-field loses its numeric input behaviour and its range validation.

What replaces Frame, Toast and ContextualSaveBar?

App Bridge. Frame used to host the toast, loading bar and contextual save bar inside your iframe. In an App Home app the admin owns all three, so Frame and TopBar are deleted rather than migrated, navigation moves to s-app-nav, and the rest become calls on the shopify global.

The save bar needs the most care, because a broken one loses merchant edits. Put data-save-bar on a native form and App Bridge watches its fields: a change shows the bar, Save fires submit, Discard fires reset. No dirty-state tracking in React.

import type { FormEvent } from 'react';

type SubmitLike =
  | FormEvent<HTMLFormElement>
  | (SubmitEvent & { currentTarget: HTMLFormElement });

type Props = {
  initialTitle: string;
  onSave: (data: FormData) => Promise<void>;
};

export function SettingsForm({ initialTitle, onSave }: Props) {
  async function handleSubmit(event: SubmitLike) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    const savedTitle = String(data.get('title') ?? '');

    try {
      await onSave(data);
      const field = form.elements.namedItem('title') as
        | (Element & { value: string; defaultValue: string })
        | null;
      if (field) {
        const editedSinceSubmit = field.value !== savedTitle;
        field.defaultValue = savedTitle;
        if (!editedSinceSubmit) form.reset();
      }
      shopify.toast.show('Settings saved');
    } catch {
      shopify.toast.show("Settings couldn't be saved", { isError: true });
    }
  }

  return (
    <form data-save-bar data-discard-confirmation onSubmit={handleSubmit}>
      <s-section heading="Settings">
        <s-text-field label="Title" name="title" defaultValue={initialTitle} />
      </s-section>
    </form>
  );
}

The SubmitLike union follows Shopify’s own example. Two other details are easy to miss. The form is captured before the await, because currentTarget is only set while the event is being dispatched and reads as null afterwards. And after a successful save, the saved value has to become the field’s defaultValue, otherwise Discard rolls back to the value the page loaded with. The reset() that clears the bar is skipped if the merchant kept typing while the request was in flight. On failure the bar stays up and the input survives, which is what Shopify’s guide asks for.

If dirty state comes from somewhere a native form cannot see, use the programmatic ui-save-bar with shopify.saveBar.show(id) and hide(id). Do not mix the two approaches on one form.

Why do the unit tests fail after migrating?

Because jsdom never loads the CDN script. Your s-* elements stay un-upgraded hosts with no shadow DOM, so a query for a role that only exists inside the component finds nothing.

Query the tag and its attributes instead. Dispatch real DOM events rather than calling a React prop directly, since calling the prop can hide a broken browser binding. Mock the window.shopify methods the slice uses, and stub showOverlay(), hideOverlay() and toggleOverlay() if you control an s-modal from code. Then walk the slice in a development store: a test against an un-upgraded element cannot tell you whether the real one works.

How do you remove @shopify/polaris for good?

When the last embedded route is done, delete AppProvider (the Polaris React one), Frame, PolarisTestProvider, the Polaris stylesheet and any .Polaris- overrides, then uninstall @shopify/polaris and @shopify/polaris-icons. Then make sure nobody adds them back:

// eslint.config.js
export default [
  {
    files: ['app/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': [
        'error',
        {
          paths: [
            { name: '@shopify/polaris', message: 'Use Polaris web components (s-*) instead.' },
            { name: '@shopify/polaris-icons', message: 'Pass an icon name to s-icon instead.' },
          ],
        },
      ],
    },
  },
];

If a public or standalone route outside the admin still needs Polaris React, keep the dependency and narrow files to the embedded routes. Write down where the boundary is.

What we would do

For an app with a dozen routes and no custom pickers, we would start now: React 19 as its own release, then one route per pull request, beginning with the settings page because it exercises the save bar. While you are in the app shell, check your session token verification too, and whether the install could ask for optional scopes instead of everything up front.

We would wait if the app leans on Autocomplete, Combobox or Tabs and React 19 is blocked by another dependency. In that case, migrate the shell and the simple routes, leave the complex screens on Polaris React with a scoped lint rule, and come back when there is time to build the missing controls properly.

Whoooop builds and maintains embedded Shopify apps, and a route-by-route Polaris migration is the kind of work we take on alongside a client’s own team. Our Shopify development page explains how we work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch