Cloudflare Workers
Deploy a Remix 3 app to Cloudflare Workers by composing remix() with @cloudflare/vite-plugin. Cloudflare's plugin owns the runtime story end to end: dev requests run inside workerd (real bindings, real runtime), vite preview serves the production build through Miniflare, and wrangler deploy publishes it.
Configuration
The examples import from pitlane/* paths, which come from the pitlane package. The cloudflare template depends on scoped packages such as @pitlane/vite-plugin-remix, @pitlane/assets, and @pitlane/theme, so add pitlane before switching its imports. Put it in dependencies, because the Worker imports pitlane/assets at runtime, and one that reads D1 imports pitlane/data-table-d1 too:
npm
npm add pitlaneyarn
yarn add pitlanepnpm
pnpm add pitlanebun
bun add pitlanedeno
deno add npm:pitlanevp
vp add pitlanevlt
vlt add pitlanenub
nub add pitlaneThe Pitlane package guide lists the remix and vite versions it requires and the standalone scoped packages. Then install the platform pieces:
npm
npm add -D @cloudflare/vite-plugin wrangleryarn
yarn add -D @cloudflare/vite-plugin wranglerpnpm
pnpm add -D @cloudflare/vite-plugin wranglerbun
bun add -D @cloudflare/vite-plugin wranglerdeno
deno add -D npm:@cloudflare/vite-plugin npm:wranglervp
vp add -D @cloudflare/vite-plugin wranglervlt
vlt add -D @cloudflare/vite-plugin wranglernub
nub add -D @cloudflare/vite-plugin wranglerPoint Cloudflare's plugin at the ssr environment and let it own dev-time request handling with serverHandler: false:
import { remix } from "pitlane/vite-plugin-remix";
import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix({ serverHandler: false }), cloudflare({ viteEnvironment: { name: "ssr" } })],
});wrangler.jsonc points at your server entry and the client build output:
{
"name": "my-remix-app",
"main": "app/entry.server.tsx",
"assets": { "directory": "dist/client" },
"compatibility_date": "2026-04-02",
"compatibility_flags": ["nodejs_compat"],
}Your server entry is already a Workers module: the default-exported router is the fetch handler. Extra worker events compose around it:
export default router;
// or, with queue/cron handlers:
export default {
fetch: router.fetch,
async queue(batch) {
/* ... */
},
};Bindings
Read bindings through the cloudflare:workers module. In dev they come from workerd's local emulation, in production from the deployed worker:
import { env } from "cloudflare:workers";
let value = await env.MY_KV.get("key");A D1 binding gets you a remix/data-table database through createD1Database() from pitlane/data-table-d1, which supplies the async driver D1 needs in place of the synchronous SQLite one.
Prerendering and frame navigation
Cloudflare's default asset-first routing serves generated HTML before your Worker ever runs. It selects assets by URL, so a header-based frame request to a document URL can receive the full document and duplicate the page shell.
Fully static frame navigation
Prefer separate prerendered frame URLs. Both documents and frame responses remain static, with no request-time SSR or application Worker invocation.
Prerender /platform/ as a complete document and /_frames/main/platform/ as frame content. Use href="/platform/", data-rmx-target="main", and data-rmx-src="/_frames/main/platform/" on the link. Remix fetches the frame URL while keeping /platform/ in the address bar. Use the frame URL for the initial <Frame src> too.
Keep the server entry for build-time rendering and list both kinds of route in prerender. A portable app can build with remix({ prerender: [...] }) alone. An app that needs Cloudflare bindings while rendering can keep the Cloudflare Vite plugin for the build. Deploy only dist/client with a separate assets-only configuration:
{
"name": "my-static-remix-app",
"assets": {
"directory": "dist/client",
"not_found_handling": "none",
},
"compatibility_date": "2026-04-02",
}This configuration has no main, ASSETS binding, or run_worker_first. Select it explicitly for both preview and deployment:
vp build
vpx wrangler dev --config wrangler.static.jsonc
# After checking the static preview:
vpx wrangler deploy --config wrangler.static.jsoncUse this assets-only preview to prove the site works without its SSR entry. vp preview can still run the built server and hide missing prerendered files. Check initial loads, frame-targeted links, hydration, and direct document reloads. A missing frame file must return 404, never a document-shell fallback.
Cloudflare's default HTML handling serves directory indexes at trailing-slash URLs. Keep those slashes in both document and frame destinations to avoid redirects. No frame headers are needed.
Optional hybrid rendering
Choose the Worker-first recipe below only when frame content must render at request time. Document responses stay prerendered, but frame navigation depends on a running SSR app. This alternative gives up fully static deployment.
| Request | Response source |
|---|---|
| Document request for a prerendered path | Static HTML |
| Frame request | Runtime app rendering |
| JavaScript, CSS, or another existing asset | Static file |
| Non-prerendered route or mutation | Runtime app |
With run_worker_first: true, every request invokes the Worker, including requests that return static HTML or assets. Those static responses do not require SSR, but they still incur Worker invocations.
This recipe tells frame requests apart by their x-remix-frame and x-remix-target headers, and assumes your controller recognizes them too. Remix's default frame resolver sends x-remix-frame: true on every frame request, plus x-remix-target when the frame is named, so an app that calls run() without a resolveFrame needs no change.
A custom resolveFrame(src, options) builds its own request instead. Build its headers like this and pass them to its fetch call:
let headers = new Headers({ accept: "text/html", "x-remix-frame": "true" });
if (options?.target) headers.set("x-remix-target", options.target);Set x-remix-frame even when no target is named. Keep the resolver's method, form-body encoding, abort signal, and response handling unchanged. This fragment only changes its headers.
Run the Worker first and give it an asset binding:
{
"name": "my-remix-app",
"main": "app/entry.server.tsx",
"assets": {
"directory": "dist/client",
"binding": "ASSETS",
"run_worker_first": true,
},
"compatibility_date": "2026-04-02",
"compatibility_flags": ["nodejs_compat"],
}Wrap the router at the server entry. Keep any routes export needed by prerender: true:
import { env } from "cloudflare:workers";
import { router } from "./router.ts";
export { routes } from "./routes.ts";
export default {
async fetch(request: Request) {
if (
(request.method === "GET" || request.method === "HEAD") &&
!request.headers.has("x-remix-frame") &&
!request.headers.has("x-remix-target")
) {
let asset = await env.ASSETS.fetch(request);
if (asset.status !== 404) return asset;
}
return router.fetch(request);
},
};Generate binding types with vpx wrangler types after adding ASSETS. Frame requests and mutations go straight to the router. Ordinary GET/HEAD requests can use the prerendered document or another static asset. A missing asset falls through to the router. The controller still decides which content belongs in each frame.
Keep assets.not_found_handling unset (its default is "none"). A "single-page-application" fallback answers missing assets with a 200 shell, preventing the 404 fallthrough to the router.
Both changes are required. run_worker_first without the wrapper stops serving assets, and the wrapper without run_worker_first never sees a request that matches an asset. Cloudflare supports selective Worker-first paths, but every frame-resolved prerendered path must be included, with and without its trailing slash. Excluding it restores the asset-first behavior that causes this bug.
Keep the Vite plugins composed as above, with prerender added to remix({ serverHandler: false, prerender: true }). The pitlane/vite-plugin-remix plugin does not change your Wrangler routing settings.
Check the production build with vp preview. A request to a frame-resolved route carrying the frame headers must return frame content without an asset redirect or a document shell. The wrapper forwards the URL unchanged, so the controller must handle the trailing-slash forms used by your links and frame sources. Register both /page and /page/ with the same action if both are reachable. An ordinary document request should still serve the prerendered HTML. Click between frame-targeted links and confirm that the page retains one header and footer.
Local development and preview
vp dev # dev server — SSR runs inside workerd with your bindings
vp build # production build
vp preview # serve the production build through MiniflareFor local secrets during vp dev, use a gitignored .dev.vars file next to wrangler.jsonc. Non-secret values can live in wrangler.jsonc under "vars".
Deploy with the CLI
vpx wrangler login
vp build
vpx wrangler deployProduction secrets are write-only through Wrangler:
vpx wrangler secret put MY_SECRETDeploy with GitHub Actions
Create an API token at My Profile → API Tokens using the Edit Cloudflare Workers template, and store it as the CLOUDFLARE_API_TOKEN repository secret.
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
deployments: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1.21.1
with:
cache: true
- run: vp install --frozen-lockfile
- run: vp build
- run: vpx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}wrangler deploy reads wrangler.jsonc, so the workflow needs no name, paths, or account flags. The config is the single source of truth locally and in CI.
Client-only apps
A client-only Remix 3 app runs remix() in SPA mode and drops @cloudflare/vite-plugin: server: false builds no server environment, so there is no worker to run. The plugin's remaining job is component HMR, so it is worth keeping in a static build.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Remix App</title>
<script type="module" src="/app/main.tsx"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>import { createRoot, on, type Handle } from "remix/component";
function App(handle: Handle) {
let count = 0;
return () => (
<button
mix={[
on("click", () => {
count++;
handle.update();
}),
]}
>
Count: {count}
</button>
);
}
createRoot(document.getElementById("app")!).render(<App />);{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component",
},
}import { remix } from "pitlane/vite-plugin-remix";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [remix({ server: false })],
});vite build emits the site into dist/. The Worker config becomes assets-only: no main, and single-page-application fallback serves index.html on deep links:
{
"name": "my-remix-spa",
"compatibility_date": "2026-04-02",
"assets": {
"directory": "./dist",
"not_found_handling": "single-page-application",
},
}Deploys are unchanged: vpx wrangler deploy from the CLI, or the same GitHub Actions workflow above.