To migrate a Shopify app from Remix to React Router, do it in two moves. First turn the Remix 2 app into a React Router 7 app with the official codemod. Then swap @shopify/shopify-app-remix for @shopify/shopify-app-react-router. Pin React Router to 7, run Node 22, and check four removed features before you touch a file.
Shopify’s own upgrade guide on the template wiki covers the mechanics. It was written for the 0.x package in mid-2025, though, and the package reached 4.0.0 on 21 October 2026. Some of its diffs no longer compile against the current package, and one default it never mentions changes how your app looks. This post follows the same order and flags where the wiki has drifted. Versions are as of October 2026.
Do you have to migrate at all?
Not today. @shopify/shopify-app-remix still ships releases (6.0.2 landed on 8 October 2026), and the Remix template’s README now opens with “Remix is now React Router” and points new projects elsewhere. The wiki is blunter: support for the Remix package “will be limited to critical security issues”.
So nothing breaks this quarter. What you lose is the new work. The Polaris web component integration and the polarisUrl option exist only in the React Router package, and Shopify’s app template is now built on it. An app that stays on Remix drifts further from the template with every release, and each fix you want from it becomes a manual port.
What the React Router package dropped
Check these four before you start. Any one of them can stop the migration outright.
REST
The Admin REST resources are gone. Move the calls to GraphQL, or use the REST client in @shopify/admin-api-client directly. If you already set the Remix package’s REST-removal future flag, you have nothing to do here.
Non-embedded apps
Shopify puts these at about 2% of Remix apps. In 2.0.0 the package removed the non-embedded surface completely: shopifyApp rejects isEmbeddedApp, and AppProvider always renders App Bridge. A non-embedded app has two choices. Become embedded, or stay on Remix.
The auth-code flow
Embedded apps created before 25 January 2024 may still use it. Turn on unstable_newEmbeddedAuthStrategy on the Remix package and ship that by itself first, so a login problem shows up as a login problem and not as part of a framework upgrade. Then delete the flag when you swap packages. The React Router package’s FutureFlags type only knows expiringOfflineAccessTokens, so leaving it in fails to compile:
error TS2353: Object literal may only specify known properties, and 'unstable_newEmbeddedAuthStrategy' does not exist in type 'FutureFlags'.
AppProxyForm
Replace <AppProxyForm> with a plain <form>. Shopify says very few apps use it.
Step 1: adopt the Remix future flags
Do this on Remix, merge it and let it run in production. Single fetch is the step Shopify’s guide flags for authentication, and isolating it here means it breaks in a codebase you already know, with no renamed imports to muddy the diff.
Remove installGlobals from vite.config.ts, then enable v3_singleFetch next to the other v3_ flags. Single fetch changes how response headers travel. The Shopify library communicates through headers on the responses it throws: re-auth redirects, App Bridge bounce pages, 401s for expired session tokens. That means every route that calls authenticate.admin(request) needs a headers export that hands them to boundary.headers. Shopify’s guide marks this as the important step. Without the export, a route drops the headers the library set, and re-authentication inside the admin stops working on that route.
Single fetch also retires json(). Return plain objects from every loader and action under app/routes/.
Step 2: run the codemod and keep React Router on 7
React Router’s upgrade guide does the bulk of this:
npx codemod remix/2/react-router/upgrade && npm install
It renames the packages (@remix-run/react and @remix-run/server-runtime become react-router, @remix-run/node becomes @react-router/node, @remix-run/dev becomes @react-router/dev) and rewrites the imports. You then add app/routes.ts. The Shopify template keeps its flat file routes:
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes();
Replace the remix({ future: {...} }) block in vite.config.ts with a bare reactRouter(). Add .react-router/ to .gitignore, swap @remix-run/node for @react-router/node in env.d.ts and the tsconfig types, and add ".react-router/types/**/*" to include plus "rootDirs": [".", "./.react-router/types"]. Change your typecheck script to react-router typegen && tsc --noEmit, which is what the current template runs.
The version you install matters more than anything the codemod does. React Router’s latest tag on npm is 8.5.1. @shopify/shopify-app-react-router 4.0.0 declares a peer dependency of react-router: ^7.6.2, and React Router 8 itself requires React 19.2.7 or later and Node 22.22. The template pins the whole family to an exact 7.x release and stays on React 18:
{
"engines": { "node": ">=22.12" },
"dependencies": {
"@react-router/dev": "7.18.2",
"@react-router/fs-routes": "7.18.2",
"@react-router/node": "7.18.2",
"@react-router/serve": "7.18.2",
"react-router": "7.18.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}
Copy those pins. A bare npm install react-router@latest on a React 18 app ends in ERESOLVE unable to resolve dependency tree. Moving to 8 is a separate project for later, and our React Router v8 flags-first walkthrough covers it once Shopify’s peer range allows it.
Package 2.0.0 also dropped Node 20, so your production runtime and CI image need Node 22 before the package swap, not after.
Step 3: swap the Shopify package
Replace the dependency, then change every import path from @shopify/shopify-app-remix to @shopify/shopify-app-react-router. That means the /adapters/node side-effect import and /server in shopify.server.ts, boundary in your routes, and LoginError and LoginErrorType in the login error module.
Here is a route after the swap. It is the pattern every authenticated route should follow, and it type-checks against @shopify/shopify-app-react-router 4.0.0 and react-router 7.18.2:
import type { HeadersFunction, LoaderFunctionArgs } from "react-router";
import { useLoaderData, useRouteError } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server";
type ProductsQuery = {
data?: { products: { nodes: { id: string; title: string }[] } };
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin } = await authenticate.admin(request);
const response = await admin.graphql(
`#graphql
query RecentProducts {
products(first: 10, sortKey: UPDATED_AT, reverse: true) {
nodes { id title }
}
}`,
);
const { data }: ProductsQuery = await response.json();
// Return a plain object: json() went away with single fetch.
return { products: data?.products.nodes ?? [] };
};
export default function Products() {
const { products } = useLoaderData<typeof loader>();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.title}</li>
))}
</ul>
);
}
// Lets the library's thrown redirects and 401s keep their headers.
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};
Where the wiki is out of date
The wiki tells you to write <AppProvider embedded apiKey={apiKey}> in app/routes/app.tsx and to wrap the login page in <AppProvider embedded={false}>. Both broke in 2.0.0, which removed the embedded prop. TypeScript now reports Property 'embedded' does not exist on type 'IntrinsicAttributes & AppProviderProps'. Write <AppProvider apiKey={apiKey}>. Then follow the changelog’s advice for login: render the login page’s markup without a provider, or move the login(request) call into the root route’s action and drop the separate /auth/login UI route.
The wiki’s diff also pins the package to 0.1.1. Install the current major.
Pin Polaris 1 while you migrate
New in 4.0.0, AppProvider loads polaris-2.js by default, and Polaris 2 renders the Admin Next theme, so the app’s UI changes even if you didn’t touch a single component. Don’t review a framework migration and a visual redesign in the same pull request. Set polarisUrl in both places so the script tag and the preload Link header agree:
const shopify = shopifyApp({
// ...existing config
polarisUrl: "https://cdn.shopify.com/shopifycloud/polaris-1.js",
});
Pass the same URL to <AppProvider apiKey={apiKey} polarisUrl="...">, then remove both in a later PR once someone has looked at the app under Polaris 2.
Two smaller breaks matter if you handle webhooks. subTopic is gone from the webhook context (use webhook filters, which are mandatory for the metaobjects/* topics). Since 3.0.0, eventId and resourceId are no longer set on events deliveries, so dedupe those on webhookId instead.
What about Polaris React?
The package is built around Polaris web components, but it does not force you to rewrite the UI on day one. Shopify documents running Polaris React and web components side by side, at the cost of shipping both libraries and mixing two design versions. Treat that as a separate piece of work after the framework move. Our guide to moving Polaris React to web components in slices covers the order that keeps each PR reviewable.
What we would do
Ship it as four separate deploys: future flags on Remix, the codemod with React Router pinned to 7, the package swap with Polaris pinned to 1.x, and the Polaris 2 review last. Single fetch breaks headers, the codemod breaks imports, the swap breaks auth config and Polaris 2 changes how everything looks. Kept apart, a broken embedded login points at one deploy.
If the app is non-embedded or leans heavily on REST, stay on the Remix package for now. Remix still receives security fixes, and you can schedule the GraphQL rewrite or the move to embedded on its own timeline.
We migrate and maintain embedded Shopify apps as part of our Shopify development work, from the Remix template through to Functions and checkout extensions. If yours is stuck behind one of the blockers above, that is usually where we start.