---
title: "Deploy to Cloudflare Workers"
description: "Run a Remix 3 app on Cloudflare Workers with pitlane/vite-plugin-remix and the Cloudflare Vite plugin, with workerd in dev, Miniflare preview, and wrangler deploy."
url: "https://pitlane.tools/deploy/cloudflare"
---

# Deploy to Cloudflare Workers

Deploy a Remix 3 app to [Cloudflare Workers](https://developers.cloudflare.com/workers/) by composing [`remix()`](https://pitlane.tools/package/vite-plugin-remix/function/remix.md) with [`@cloudflare/vite-plugin`](https://developers.cloudflare.com/workers/vite-plugin/). Cloudflare's plugin owns the runtime story end to end: dev requests run inside [workerd](https://github.com/cloudflare/workerd) (real bindings, real runtime), `vite preview` serves the production build through Miniflare, and `wrangler deploy` publishes it.

> [!TIP]
>
> **Start from the template**
>
> `npx giget github:pitlane-tools/templates/cloudflare my-app` scaffolds a working guest book app wired for this guide. See [pitlane-tools/templates](https://github.com/pitlane-tools/templates).

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

```sh
npm add pitlane
```

With yarn, pnpm, bun, vp, vlt, or nub, run the same command with the manager's name in place of `npm`. With deno, put its name in place of `npm` and write each package as `npm:<package>`.

The [Pitlane package guide](https://pitlane.tools/guides/umbrella.md) lists the `remix` and `vite` versions it requires and the standalone scoped packages. Then install the platform pieces:

```sh
npm add -D @cloudflare/vite-plugin wrangler
```

With yarn, pnpm, bun, vp, vlt, or nub, run the same command with the manager's name in place of `npm`. With deno, put its name in place of `npm` and write each package as `npm:<package>`.

Point Cloudflare's plugin at the `ssr` environment and let it own dev-time request handling with [`serverHandler: false`](https://pitlane.tools/package/vite-plugin-remix/interface/RemixPluginOptions.md#serverhandler):

```ts title="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:

```jsonc title="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:

```ts
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:

```ts
import { env } from "cloudflare:workers";

let value = await env.MY_KV.get("key");
```

> [!TIP]
>
> **Tip**
>
> Importing `cloudflare:workers` makes the SSR bundle resolvable only inside workerd. That's expected. The `pitlane/vite-plugin-remix` preview server detects it and steps aside so Cloudflare's Miniflare preview takes over.

A D1 binding gets you a `remix/data-table` database through [`createD1Database()`](https://pitlane.tools/package/data-table-d1/function/createD1Database.md) from [`pitlane/data-table-d1`](https://pitlane.tools/guides/cloudflare-d1.md), 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](https://pitlane.tools/guides/prerendering.md#fully-static-frame-navigation). 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`](https://pitlane.tools/package/vite-plugin-remix/interface/RemixPluginOptions.md#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:

```jsonc title="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:

```sh
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](https://developers.cloudflare.com/workers/static-assets/routing/advanced/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:

```ts
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:

```jsonc title="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`:

```ts title="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](https://developers.cloudflare.com/workers/static-assets/routing/worker-script/#run-worker-first-for-selective-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

```sh
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`](https://developers.cloudflare.com/workers/configuration/secrets/#local-development-with-secrets) file next to `wrangler.jsonc`. Non-secret values can live in `wrangler.jsonc` under `"vars"`.

## Deploy with the CLI

```sh
vpx wrangler login
vp build
vpx wrangler deploy
```

Production secrets are write-only through Wrangler:

```sh
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.

```yaml title=".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](https://pitlane.tools/guides/spa.md) 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](https://pitlane.tools/guides/hmr.md#component-hmr), so it is worth keeping in a static build.

```html title="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>
```

```tsx title="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 />);
```

```jsonc title="tsconfig.json"
{
    "compilerOptions": {
        "jsx": "react-jsx",
        "jsxImportSource": "remix/component",
    },
}
```

```ts title="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:

```jsonc title="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](#deploy-with-github-actions) above.
