How to migrate from NuxtHub to direct Nuxt on Cloudflare Workers

This is the end-state migration we use in your-project: Nuxt SSR on Cloudflare Workers (no NuxtHub), with KV/R2/D1 bindings, Wrangler deploy, and “remote” dev against Cloudflare resources.

Worked with:

nuxthub 0.9 nuxt 4.2.2 wrangler 4.55.0

This repo’s Cloudflare resources

  • R2 bucket: YOUR_R2_BUCKET
  • D1 database: YOUR_D1_NAME (YOUR_D1_ID)
  • KV namespace: YOUR_KV_NAME (YOUR_KV_ID)

Target shape (what we ended up with)

  • Nuxt (v4 here) + Nitro deploys to Cloudflare Workers using the Nitro Cloudflare provider.
  • nitro.cloudflare.deployConfig = true so the build generates .output/server/wrangler.json.
  • A repo-root wrangler.toml provides the binding IDs/names (KV/R2/D1) and asset directory.
  • Deploy uses the generated config: wrangler --cwd .output/server deploy.

Concrete reference (example)

These are the exact patterns we used so nothing is ambiguous.

nuxt.config.ts (Nitro preset + deployConfig)

    nitro: {
  preset: "cloudflare_module",
  cloudflare: {
    deployConfig: true,
    nodeCompat: false
  }
}

  

wrangler.toml (bindings + assets)

We used binding names DB, KV, R2 and served Nuxt public assets via assets = { directory = ".output/public" }.

If you want “remote dev against prod only”, you can point preview resources to prod resources (example shows preview_id == id and preview_bucket_name == bucket_name).

    name = "your-worker-name"
main = ".output/server/index.mjs"
compatibility_date = "YYYY-MM-DD"

assets = { directory = ".output/public" }

[[d1_databases]]
binding = "DB"
database_name = "YOUR_D1_NAME"
database_id = "YOUR_D1_ID"

[[kv_namespaces]]
binding = "KV"
id = "YOUR_KV_ID"
preview_id = "YOUR_KV_PREVIEW_ID"

[[r2_buckets]]
binding = "R2"
bucket_name = "YOUR_R2_BUCKET"
preview_bucket_name = "YOUR_R2_PREVIEW_BUCKET"

  

Notes:

  • compatibility_date is Wrangler’s Worker compatibility date. (Nuxt/Nitro also has its own compatibilityDate field; they don’t have to match, but keeping them close avoids surprises.)

package.json scripts (remote dev + deploy)

    {
  "scripts": {
    "dev": "pnpm run build && rm -f .wrangler/deploy/config.json && wrangler --cwd .output/server dev --remote --port 3000",
    "build": "nuxt build",
    "deploy": "pnpm run build && rm -f .wrangler/deploy/config.json && wrangler --cwd .output/server deploy"
  }
}

  

1) Remove NuxtHub

  1. Remove NuxtHub dependencies and scripts:
    • Delete @nuxthub/core dependency.
    • Delete nuxthub dependency.
    • Replace nuxthub preview/deploy scripts with Wrangler-based scripts.
  2. Remove NuxtHub module/config from nuxt.config.*:
    • Remove @nuxthub/core from modules.
    • Remove hub: { database/kv/blob } config block.
  3. Remove NuxtHub env vars (examples):
    • Delete NUXT_HUB_ENV, NUXT_HUB_PROJECT_KEY from .env / .env.example.

2) Switch Nitro to Cloudflare Workers provider (the key config)

In nuxt.config.*:

    nitro: {
  preset: "cloudflare_module",
  cloudflare: {
    deployConfig: true,
    nodeCompat: false
  }
}

  

Notes:

  • deployConfig: true is what makes Nitro emit .output/server/wrangler.json.
  • nodeCompat:
    • We keep this repo at false and shim node:crypto to WebCrypto (see nuxt.config.ts nitro alias).
    • If you actually need other Node built-ins at runtime, set it to true and expect higher memory usage while bundling.

3) Map NuxtHub “Generated Bindings” to Wrangler bindings

NuxtHub shows something like:

  • R2 bucket: BLOB (name like example-app)
  • D1 database: DB (id like e8658e2b-...)
  • KV namespace: KV (id like 0e234b8f...)

Create (or update) repo-root wrangler.toml using the same binding names your server code expects:

    name = "your-worker-name"
main = ".output/server/index.mjs"
compatibility_date = "YYYY-MM-DD"

assets = { directory = ".output/public" }

[[d1_databases]]
binding = "DB"
database_name = "YOUR_D1_NAME"
database_id = "YOUR_D1_ID"

[[kv_namespaces]]
binding = "KV"
id = "YOUR_KV_ID"
preview_id = "YOUR_KV_PREVIEW_ID"

[[r2_buckets]]
binding = "R2"
bucket_name = "YOUR_R2_BUCKET"
preview_bucket_name = "YOUR_R2_PREVIEW_BUCKET"

  

Notes:

  • Wrangler will often require preview_id / preview_bucket_name for dev. If you accept the risk, you can point preview to prod resources; otherwise create separate preview resources.

4) Replace hubKV() / hubBlob() / hubDatabase() in server code

NuxtHub injected helpers like hubKV() / hubBlob() / hubDatabase(); direct Workers does not.

End-state pattern we used:

  • Read bindings from event.context.cloudflare.env (Nitro Cloudflare runtime).
  • Thread event through DB/storage helpers so they can access bindings.

Concrete changes:

  1. Add small binding accessor helpers:
    • server/utils/cloudflare/bindings.ts (typed access to DB, KV, R2)
    • server/utils/cloudflare/kv.ts (JSON helpers for KV)
    • server/utils/cloudflare/types.ts (minimal binding types)
  2. D1 (Drizzle) changes:
    • useDrizzle(event) instead of useDrizzle().
    • Update call sites to pass the H3 event.
  3. KV changes:
    • Replace hubKV().get/set(...) with kvGetJson(event, key) / kvPutJson(event, key, value) (or equivalent).
  4. R2 changes:
    • Replace hubBlob() APIs with the Cloudflare R2 bucket API (get/put/head/list/delete).
    • Update call sites to use useBlobStorage(event).

Reference implementation: binding access + KV + D1 + R2 (example-app)

Minimal binding types (server/utils/cloudflare/types.ts):

    import { drizzle } from "drizzle-orm/d1"

export type D1Database = Parameters<typeof drizzle>[0]

export type KVNamespace = {
  get<T = unknown>(key: string, options: { type: "json" }): Promise<T | null>
  put(key: string, value: string): Promise<void>
}

export type R2Object = {
  arrayBuffer(): Promise<ArrayBuffer>
  httpMetadata?: { contentType?: string }
}

export type R2Bucket = {
  get(key: string): Promise<R2Object | null>
  put(
    key: string,
    value: string | ArrayBuffer | ArrayBufferView,
    options?: { httpMetadata?: { contentType?: string } },
  ): Promise<void>
  delete(key: string): Promise<void>
  head(key: string): Promise<unknown | null>
  list(options: {
    prefix?: string
    cursor?: string
  }): Promise<{ objects: Array<{ key: string }>; cursor?: string }>
}

  

Binding access (server/utils/cloudflare/bindings.ts):

    import { type H3Event, createError } from "h3"
import type { D1Database, KVNamespace, R2Bucket } from "./types"

export type CloudflareEnv = {
  DB: D1Database
  KV: KVNamespace
  R2: R2Bucket
}

export function getCloudflareEnv(event: H3Event): CloudflareEnv {
  const cloudflare = (
    event.context as unknown as { cloudflare?: { env?: CloudflareEnv } }
  ).cloudflare
  const env = cloudflare?.env
  if (!env) {
    throw createError({
      statusCode: 500,
      statusMessage:
        "Cloudflare bindings are missing (expected event.context.cloudflare.env)",
    })
  }
  return env
}

export function getD1Database(event: H3Event): D1Database {
  return getCloudflareEnv(event).DB
}

export function getKVNamespace(event: H3Event): KVNamespace {
  return getCloudflareEnv(event).KV
}

export function getR2Bucket(event: H3Event): R2Bucket {
  return getCloudflareEnv(event).R2
}

  

KV JSON helpers (server/utils/cloudflare/kv.ts):

    import type { H3Event } from "h3"
import { getKVNamespace } from "./bindings"

export async function kvGetJson<T>(
  event: H3Event,
  key: string,
): Promise<T | null> {
  const kv = getKVNamespace(event)
  return await kv.get<T>(key, { type: "json" })
}

export async function kvPutJson(
  event: H3Event,
  key: string,
  value: unknown,
): Promise<void> {
  const kv = getKVNamespace(event)
  await kv.put(key, JSON.stringify(value))
}

  

D1 (Drizzle) helper (server/utils/drizzle.ts):

    import { drizzle } from "drizzle-orm/d1"
import * as schema from "../database/schema"
import type { H3Event } from "h3"
import { getD1Database } from "~~/server/utils/cloudflare/bindings"

export function useDrizzle(event: H3Event) {
  return drizzle(getD1Database(event), { schema })
}

  

R2 blob helper (server/utils/storage/useBlobStorage.ts):

  • const bucket = getR2Bucket(event)
  • store/retrieve as Blob and ArrayBuffer (bucket.put(key, await blob.arrayBuffer(), { httpMetadata: ... }))
  • helper methods used in handlers: putJson, getJson, getBlob, getBlobText, exists, list, delete, rename, clone

5) Remove Node-only server code (if you want nodeCompat: false)

Workers runtime is not Node. Typical fixes:

  • Replace node:crypto usage with WebCrypto (we used crypto.subtle.digest for ETags).
  • Remove Buffer usage in server handlers (use ArrayBuffer/Uint8Array/Blob).

6) Dev workflow (remote)

We used remote dev against Cloudflare resources:

  1. Login once:
    • pnpm exec wrangler login
  2. Start dev:
    • pnpm run dev

End-state script (this project):

  • pnpm run dev runs pnpm run build then wrangler --cwd .output/server dev --remote --port 3000.

Notes:

  • --no-bundle does not work with Nitro’s multi-file server output (missing chunk modules in preview). Use the default bundling behavior.

7) Deploy workflow

We deploy using Nitro’s generated .output/server/wrangler.json:

  • pnpm run deploy

End-state script (this project):

    pnpm run build
rm -f .wrangler/deploy/config.json
wrangler --cwd .output/server deploy

  

Why the rm:

  • Wrangler stores .wrangler/deploy/config.json and can error if it conflicts with the generated .output/server/wrangler.json base path. Removing it avoids the ambiguity.

8) Set production secrets/vars in Cloudflare (don’t rely on .env)

Set secrets in Cloudflare (examples):

  • NUXT_SESSION_PASSWORD
  • NUXT_OAUTH_MICROSOFT_CLIENT_ID
  • NUXT_OAUTH_MICROSOFT_CLIENT_SECRET

Non-secrets can go in wrangler.toml [vars] or via wrangler CLI.

9) Cutover + NuxtHub cleanup

  1. Deploy the Worker and verify the Workers.dev URL.
  2. Route your real hostname to the Worker (Workers route or DNS), verify auth + KV/R2/D1.
  3. Leave NuxtHub project around briefly as rollback.
  4. Remove NuxtHub project routes/domains and disable NuxtHub deploys.
  5. Delete/archive the NuxtHub project without deleting shared resources (D1/KV/R2 are now used by the Worker).

Troubleshooting checklist

  • Remote dev says “must be logged in”: pnpm exec wrangler login.
  • Wrangler demands preview resources: add preview_id / preview_bucket_name (or create separate preview resources).
  • Wrangler deploy complains about multiple configs: remove .wrangler/deploy/config.json and deploy via wrangler --cwd .output/server deploy.
  • Build “Killed (137)”: OS OOM killer. Reduce memory pressure or (if needed) toggle nodeCompat/bundle size; consider adding swap.