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 = trueso the build generates.output/server/wrangler.json.- A repo-root
wrangler.tomlprovides 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_dateis Wrangler’s Worker compatibility date. (Nuxt/Nitro also has its owncompatibilityDatefield; 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
- Remove NuxtHub dependencies and scripts:
- Delete
@nuxthub/coredependency. - Delete
nuxthubdependency. - Replace
nuxthub preview/deployscripts with Wrangler-based scripts.
- Delete
- Remove NuxtHub module/config from
nuxt.config.*:- Remove
@nuxthub/corefrommodules. - Remove
hub: { database/kv/blob }config block.
- Remove
- Remove NuxtHub env vars (examples):
- Delete
NUXT_HUB_ENV,NUXT_HUB_PROJECT_KEYfrom.env/.env.example.
- Delete
2) Switch Nitro to Cloudflare Workers provider (the key config)
In nuxt.config.*:
nitro: {
preset: "cloudflare_module",
cloudflare: {
deployConfig: true,
nodeCompat: false
}
}
Notes:
deployConfig: trueis what makes Nitro emit.output/server/wrangler.json.nodeCompat:- We keep this repo at
falseand shimnode:cryptoto WebCrypto (seenuxt.config.tsnitroalias). - If you actually need other Node built-ins at runtime, set it to
trueand expect higher memory usage while bundling.
- We keep this repo at
3) Map NuxtHub “Generated Bindings” to Wrangler bindings
NuxtHub shows something like:
- R2 bucket:
BLOB(name likeexample-app) - D1 database:
DB(id likee8658e2b-...) - KV namespace:
KV(id like0e234b8f...)
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_namefor 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
eventthrough DB/storage helpers so they can access bindings.
Concrete changes:
- Add small binding accessor helpers:
server/utils/cloudflare/bindings.ts(typed access toDB,KV,R2)server/utils/cloudflare/kv.ts(JSON helpers for KV)server/utils/cloudflare/types.ts(minimal binding types)
- D1 (Drizzle) changes:
useDrizzle(event)instead ofuseDrizzle().- Update call sites to pass the H3 event.
- KV changes:
- Replace
hubKV().get/set(...)withkvGetJson(event, key)/kvPutJson(event, key, value)(or equivalent).
- Replace
- R2 changes:
- Replace
hubBlob()APIs with the Cloudflare R2 bucket API (get/put/head/list/delete). - Update call sites to use
useBlobStorage(event).
- Replace
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
BlobandArrayBuffer(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:cryptousage with WebCrypto (we usedcrypto.subtle.digestfor ETags). - Remove
Bufferusage in server handlers (useArrayBuffer/Uint8Array/Blob).
6) Dev workflow (remote)
We used remote dev against Cloudflare resources:
- Login once:
pnpm exec wrangler login
- Start dev:
pnpm run dev
End-state script (this project):
pnpm run devrunspnpm run buildthenwrangler --cwd .output/server dev --remote --port 3000.
Notes:
--no-bundledoes 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.jsonand can error if it conflicts with the generated.output/server/wrangler.jsonbase 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_PASSWORDNUXT_OAUTH_MICROSOFT_CLIENT_IDNUXT_OAUTH_MICROSOFT_CLIENT_SECRET
Non-secrets can go in wrangler.toml [vars] or via wrangler CLI.
9) Cutover + NuxtHub cleanup
- Deploy the Worker and verify the Workers.dev URL.
- Route your real hostname to the Worker (Workers route or DNS), verify auth + KV/R2/D1.
- Leave NuxtHub project around briefly as rollback.
- Remove NuxtHub project routes/domains and disable NuxtHub deploys.
- 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.jsonand deploy viawrangler --cwd .output/server deploy. - Build “Killed (137)”: OS OOM killer. Reduce memory pressure or (if needed) toggle
nodeCompat/bundle size; consider adding swap.