This guide moves a Remix v2 or React Router ↗︎ v7 application from Vercel to Cloudflare Workers. React Router v7 framework mode is the successor to Remix. Cloudflare supports Remix applications through React Router and the Cloudflare Vite plugin.
If your application uses Remix v2, upgrade to React Router v7 first. Ship that upgrade on Vercel, then switch hosting.
- SPA mode (
ssr: false) is not supported with the Cloudflare Vite plugin. Prerendering requires React Router 7.13.1 or later with thefuture.unstable_previewServerPrerenderingflag. Checkreact-router.config.tsbefore you start. - Do not use automatic project configuration on a Remix v2 application. It detects the application as a static site.
- This guide uses React Router v7. Install
@react-router/*packages with@^7, because unpinned installs can resolve a newer major version. - Review the steps in Before you begin that apply to every framework.
Copy this prompt into your coding agent. The agent stops before it deploys.
Migrate this app from Vercel to Cloudflare Workers (Workers with Static Assets, not Pages). It is either Remix v2 or React Router v7 in framework mode. Target React Router 7.x. Do not upgrade to React Router 8 in the same change.
0. Detect the framework. @remix-run/* means Remix v2. react-router plus @react-router/dev means React Router v7. If react-router.config.ts has `ssr: false`, stop and report that the Cloudflare Vite plugin does not support SPA mode. If it has `prerender`, use react-router and @react-router/* 7.13.1 or later and set `future: { unstable_previewServerPrerendering: true }`. Do not use `wrangler setup` on Remix v2.
1. Remix v2 only: upgrade to React Router v7 first (https://reactrouter.com/upgrading/remix). Commit the upgrade separately.
- If the app has loaders or actions, update @remix-run/* to the latest 2.x and turn on every v3_* future flag. Fix what breaks, then typecheck, build, and commit.
- Run `npx codemod remix/2/react-router/upgrade`. If it fails with "Registry error: HTTP request failed", run `npx codemod@0.18.13 remix/2/react-router/upgrade --no-interactive` instead.
- The codemod is incomplete. Fix these by hand:
a. Uninstall @vercel/remix, because it pins Remix 2 and breaks the install.
b. Set isbot to ^5 and engines.node to >=20.
c. Remove installGlobals() and its import.
d. Call reactRouter() in vite.config.ts with no options. Move ssr and presets into react-router.config.ts.
e. Create app/routes.ts with `export default flatRoutes() satisfies RouteConfig`. Install @react-router/fs-routes@^7 as a dev dependency.
f. Rewrite every remaining import from @remix-run/* or @vercel/remix to "react-router".
g. In tsconfig.json, add ".react-router/types/**/*" to include and set "rootDirs": [".", "./.react-router/types"]. Add .react-router/ to .gitignore.
h. Rename RemixServer to ServerRouter and RemixBrowser to HydratedRouter (from "react-router/dom") in entry files.
- Delete node_modules and the lockfile, then reinstall. A stale lockfile keeps the Remix 2 dependency tree.
- Run `react-router typegen && tsc`, build, and commit.
2. Remove Vercel:
- Delete vercel.json and .vercel/. Uninstall @vercel/remix, @vercel/react-router, @vercel/analytics, and @vercel/speed-insights. Remove their components.
- Remove vercelPreset() from react-router.config.ts and vite.config.ts.
- If app/entry.server.(t|j)sx re-exports @vercel/react-router/entry.server, uses renderToPipeableStream, or imports node:stream, replace it with an entry that uses renderToReadableStream. Use https://github.com/cloudflare/templates/blob/main/react-router-starter-template/app/entry.server.tsx as the reference. If there is no entry.server file, the default entry works.
- Remove route-level Vercel configuration (`export const config = { runtime, regions, maxDuration, memory }`).
- List other @vercel/* usage and propose Cloudflare equivalents without rewriting them.
3. Install @cloudflare/vite-plugin, wrangler, and vite@^7 as dev dependencies. The plugin requires Vite 6.1 or later. Add cloudflare({ viteEnvironment: { name: "ssr" } }) before reactRouter() in vite.config.ts. In react-router.config.ts, set `future: { v8_viteEnvironmentApi: true }`. React Router versions earlier than 7.10 name this flag unstable_viteEnvironmentApi.
4. Create workers/app.ts:
import { createRequestHandler } from "react-router";
declare module "react-router" { interface AppLoadContext { cloudflare: { env: Env; ctx: ExecutionContext } } }
const requestHandler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);
export default { fetch(request, env, ctx) { return requestHandler(request, { cloudflare: { env, ctx } }); } } satisfies ExportedHandler<Env>;
5. Create wrangler.jsonc with "name" (the repository name), "main": "./workers/app.ts", "compatibility_date": "<today>", "observability": { "enabled": true }, and "upload_source_maps": true. Do not add nodejs_compat. A compatibility date of 2026-08-04 or later already enables it. Create public/_headers:
/assets/*
Cache-Control: public, max-age=31536000, immutable
Add .wrangler/ and .dev.vars* to .gitignore.
6. Set these scripts: dev="react-router dev", build="react-router build", preview="npm run build && vite preview", deploy="npm run build && wrangler deploy", and cf-typegen="wrangler types && react-router typegen". Remove the start script. Add workers/**/* and worker-configuration.d.ts to tsconfig.json include. Run cf-typegen.
7. In loaders and actions, read bindings from `context.cloudflare.env`. Use `context.cloudflare.ctx.waitUntil` for background work.
8. Typecheck, build, and run `npm run preview -- --port <free port> --strictPort`. Request every route with curl. Confirm that /assets/* files return 200 with the immutable header.
Rules: use the repository's package manager; store local secrets in a gitignored .dev.vars file with placeholder values; never print secret values; do not deploy, log in, or change DNS.
When you finish, list the files you changed, the Vercel features without an equivalent, the secrets I must set with `npx wrangler secret put <NAME>`, and the deploy command.Skip this section if your application already uses React Router v7.
-
Run the React Router codemod:
npx codemod remix/2/react-router/upgradeyarn dlx codemod remix/2/react-router/upgradepnpx codemod remix/2/react-router/upgrade -
Complete the steps that the codemod does not cover:
- Replace
@vercel/remixwith@vercel/react-router, so you can ship the upgrade on Vercel first. - Set
isbotto^5. - Remove
installGlobals(). - Create
app/routes.tswithflatRoutes(), and install@react-router/fs-routes@^7. - Rewrite every
@remix-run/*and@vercel/remiximport toreact-router.
- Replace
-
Delete
node_modulesand your lockfile, then reinstall your dependencies. -
Build, test, and deploy the upgrade on Vercel. For more information, refer to Upgrading from Remix ↗︎.
-
Remove whichever of these Vercel packages your project uses, then install the Cloudflare Vite plugin:
npm uninstall @vercel/react-router @vercel/remix @vercel/analytics @vercel/speed-insightsyarn remove @vercel/react-router @vercel/remix @vercel/analytics @vercel/speed-insightspnpm remove @vercel/react-router @vercel/remix @vercel/analytics @vercel/speed-insightsbun remove @vercel/react-router @vercel/remix @vercel/analytics @vercel/speed-insightsnpm i -D @cloudflare/vite-plugin wrangler vite@^7yarn add -D @cloudflare/vite-plugin wrangler vite@^7pnpm add -D @cloudflare/vite-plugin wrangler vite@^7bun add -d @cloudflare/vite-plugin wrangler vite@^7 -
Remove
vercelPreset(),vercel.json,<Analytics />,<SpeedInsights />, and any routeexport const config. IfvercelPreset()generatedapp/entry.server.jsx, delete that file too. -
Add the Cloudflare plugin to
vite.config.ts, beforereactRouter():vite.config.tsts import { cloudflare } from "@cloudflare/vite-plugin"; import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()], }); -
Turn on the Vite Environment API in
react-router.config.ts:react-router.config.tsts import type { Config } from "@react-router/dev/config"; export default { ssr: true, future: { v8_viteEnvironmentApi: true, }, } satisfies Config; -
Create the Worker entry file:
workers/app.tsts import { createRequestHandler } from "react-router"; declare module "react-router" { export interface AppLoadContext { cloudflare: { env: Env; ctx: ExecutionContext }; } } const requestHandler = createRequestHandler( () => import("virtual:react-router/server-build"), import.meta.env.MODE, ); export default { fetch(request, env, ctx) { return requestHandler(request, { cloudflare: { env, ctx } }); }, } satisfies ExportedHandler<Env>; -
Create a Wrangler configuration file:
{ "$schema": "node_modules/wrangler/config-schema.json", "name": "<YOUR_PROJECT_NAME>", "main": "./workers/app.ts", // Set this to today's date "compatibility_date": "2026-11-06", "observability": { "enabled": true, }, "upload_source_maps": true, }"$schema" = "node_modules/wrangler/config-schema.json" name = "<YOUR_PROJECT_NAME>" main = "./workers/app.ts" # Set this to today's date compatibility_date = "2026-11-06" upload_source_maps = true [observability] enabled = true -
Add a
public/_headersfile so hashed assets keep a one-year cache:public/_headerstxt /assets/* Cache-Control: public, max-age=31536000, immutable -
If your application had a Node.js
entry.serverfile, replace it with one that usesrenderToReadableStream. For a reference, refer to the React Router starter template ↗︎. -
Read bindings in loaders and actions from
context.cloudflare.env:app/routes/home.tsxts export function loader({ context }: Route.LoaderArgs) { return { value: context.cloudflare.env.MY_VAR }; } -
Generate types, build, and preview your application locally:
npx wrangler typesyarn wrangler typespnpm wrangler typesnpm run buildyarn run buildpnpm run buildnpx vite previewyarn vite previewpnpm vite preview -
Deploy your application:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy
- The build fails with
Could not load virtual:react-router/server-manifest. Either thev8_viteEnvironmentApifuture flag is missing, orvercelPreset()is still configured. - React Router 7.10 or later rejects
unstable_viteEnvironmentApi. The flag was renamed tov8_viteEnvironmentApi. Some starter templates still use the old name. - The install fails with
ERESOLVE.@vercel/remixpins Remix 2, and@cloudflare/vite-pluginrequires Vite 6.1 or later. Remove@vercel/remix, upgrade Vite, and reinstall from a clean lockfile. - The codemod fails with
Registry error: HTTP request failed. This happens behind TLS-inspecting proxies. Usenpx codemod@0.18.13 remix/2/react-router/upgrade --no-interactive. - Remix
?_datarequests stop working. React Router v7 uses/<route>.dataendpoints. Browser tabs that loaded the old bundle can fail one navigation after the cutover.