Skip to content

Search the guides, deployment pages, and API reference.

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 pitlane
yarn
yarn add pitlane
pnpm
pnpm add pitlane
bun
bun add pitlane
deno
deno add npm:pitlane
vp
vp add pitlane
vlt
vlt add pitlane
nub
nub add pitlane

The 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 wrangler
yarn
yarn add -D @cloudflare/vite-plugin wrangler
pnpm
pnpm add -D @cloudflare/vite-plugin wrangler
bun
bun add -D @cloudflare/vite-plugin wrangler
deno
deno add -D npm:@cloudflare/vite-plugin npm:wrangler
vp
vp add -D @cloudflare/vite-plugin wrangler
vlt
vlt add -D @cloudflare/vite-plugin wrangler
nub
nub add -D @cloudflare/vite-plugin wrangler

Point Cloudflare's plugin at the ssr environment and let it own request handling in dev and preview with serverHandler: false:

vite.config.ts
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:

wrangler.jsonc
{
"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:

wrangler.static.jsonc
{
"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:

Terminal window
vp build
vpx wrangler dev --config wrangler.static.jsonc
# After checking the static preview:
vpx wrangler deploy --config wrangler.static.jsonc

Use 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:

wrangler.jsonc
{
"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:

app/entry.server.tsx
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

Terminal window
vp dev # dev server — SSR runs inside workerd with your bindings
vp build # production build
vp preview # serve the production build through Miniflare

For 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

Terminal window
vpx wrangler login
vp build
vpx wrangler deploy

Production secrets are write-only through Wrangler:

Terminal window
vpx wrangler secret put MY_SECRET

Deploy 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.

.github/workflows/deploy.yml
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.

index.html
<!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>
app/main.tsx
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 />);
tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "remix/component",
},
}
vite.config.ts
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:

wrangler.jsonc
{
"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.