Nuxt 3 on Cloudflare Workers: getting bindings right
The one thing that makes Workers worth it
You can host a Nuxt app almost anywhere. The reason to put it on Cloudflare Workers is not the CDN — it's the bindings. A KV namespace, a D1 database, an R2 bucket, or a Durable Object handed to your server routes as a live object, with no connection string, no pooling, and no cold-start handshake.
The catch is that bindings don't exist in the Node.js world Nuxt normally runs in. They arrive per-request, attached to the Workers execution context. Getting them into your server/api handlers — and having them work in nuxt dev — is the first real piece of plumbing in any Nuxt 3 + Workers project.
Configure the preset and the Wrangler file
Nitro has two Cloudflare targets that matter. cloudflare_module builds a Worker with a module-style entry (export default { fetch }) and serves your static output through the Workers Assets binding. cloudflare-pages targets Pages Functions. For new projects, prefer cloudflare_module: Workers Assets is where Cloudflare is investing, and you get the full Worker feature set (Durable Objects, cron triggers, queues) instead of the Pages subset.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nitro-cloudflare-dev'],
nitro: {
preset: 'cloudflare_module',
},
})
// wrangler.jsonc
{
"name": "nuxt-cf-starter",
"main": "./.output/server/index.mjs",
"compatibility_date": "2025-04-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./.output/public/",
"binding": "ASSETS",
},
"kv_namespaces": [
{ "binding": "CACHE", "id": "3f9c...", "preview_id": "8a12..." },
],
"d1_databases": [
{ "binding": "DB", "database_name": "app", "database_id": "b41e..." },
],
}
Two notes on that file. nodejs_compat is almost always required — Nitro's output, and most npm packages you'll pull in, expect node:buffer, node:crypto, or node:async_hooks to exist. And main points at build output, so nuxt build has to run before wrangler deploy. A "deploy": "nuxt build && wrangler deploy" script keeps that honest.
Reading bindings inside a server route
Nitro exposes the Workers environment on the H3 event:
event.context.cloudflare.env // your bindings + vars + secrets
event.context.cloudflare.context // ExecutionContext (waitUntil, passThroughOnException)
event.context.cloudflare.request // the raw Request
Wrap that access once so every route fails the same way when something is misconfigured:
// server/utils/cloudflare.ts
import type { H3Event } from 'h3'
export function cf(event: H3Event) {
const cloudflare = event.context.cloudflare
if (!cloudflare) {
throw createError({
statusCode: 500,
statusMessage:
'Cloudflare bindings unavailable — is nitro-cloudflare-dev enabled?',
})
}
return cloudflare
}
A KV-backed cache for a slow upstream API, using waitUntil so the write doesn't block the response:
// server/api/rates.get.ts
export default defineEventHandler(async (event) => {
const { env, context } = cf(event)
const key = 'rates:usd'
const cached = await env.CACHE.get(key, 'json')
if (cached) {
setHeader(event, 'x-cache', 'HIT')
return cached
}
const fresh = await $fetch<{ base: string; rates: Record<string, number> }>(
'https://api.example.com/v1/rates/usd',
)
context.waitUntil(
env.CACHE.put(key, JSON.stringify(fresh), { expirationTtl: 300 }),
)
setHeader(event, 'x-cache', 'MISS')
return fresh
})
And D1, which uses prepared statements with positional binding:
// server/api/posts.get.ts
interface PostRow {
id: number
title: string
created_at: string
}
export default defineEventHandler(async (event) => {
const { env } = cf(event)
const { limit = '20' } = getQuery<{ limit?: string }>(event)
const { results } = await env.DB.prepare(
`select id, title, created_at
from posts
where published = 1
order by created_at desc
limit ?1`,
)
.bind(Math.min(Number(limit) || 20, 100))
.all<PostRow>()
return results
})
Never build SQL by string concatenation here. D1 supports .batch() for multiple statements in one round trip, which matters more than you'd think: each await against D1 is a network hop from your Worker's location to the database's primary region.
Getting types instead of any
Wrangler generates types from your config. Add it to your dev flow:
npx wrangler types --env-interface CloudflareEnv
That writes worker-configuration.d.ts containing a CloudflareEnv interface with CACHE: KVNamespace, DB: D1Database, and every var you declared. Wire it into the event context:
// server/types/h3.d.ts
declare module 'h3' {
interface H3EventContext {
cloudflare: {
request: Request
env: CloudflareEnv
context: ExecutionContext
}
}
}
export {}
Now env.DB.prepare() autocompletes and a typo in a binding name is a build error. Re-run wrangler types whenever you edit wrangler.jsonc — a postinstall script or a dev script prefix is the usual place.
Local development that actually uses bindings
nitro-cloudflare-dev calls Wrangler's getPlatformProxy() and injects real bindings into nuxt dev. They're backed by Miniflare (the same workerd runtime Cloudflare runs in production) with state persisted under .wrangler/state. So local KV is a real KV implementation, and local D1 is a real SQLite file — but a different one from production.
That means migrations have to be applied twice:
# local sqlite used by nuxt dev
npx wrangler d1 migrations apply app --local
# the actual database
npx wrangler d1 migrations apply app --remote
Add .wrangler to .gitignore. When you need to debug against production data, wrangler dev --remote runs your built Worker against real remote bindings — useful, and also a very good way to write to production by accident. Keep it to a deliberate, named script.
The trade-off with nitro-cloudflare-dev: it adds a second runtime to your dev process, so startup is slower and a small number of Vite/Nitro edge cases behave differently than in Nuxt's default Node dev server. The alternative is skipping it and guarding every binding access, which trades a couple of seconds of boot time for "works locally, 500s in production." Take the slower boot.
Secrets, vars, and useRuntimeConfig
Non-secret values go in wrangler.jsonc under "vars". Secrets go in wrangler secret put API_TOKEN and never touch the repo. Both land on env — not on process.env, which barely exists on Workers.
If you use runtimeConfig, this detail will bite you exactly once:
// ✅ env overrides (NUXT_*) are applied
const config = useRuntimeConfig(event)
// ❌ on Workers, returns build-time defaults only
const config = useRuntimeConfig()
Because the environment is request-scoped, Nitro can only apply NUXT_-prefixed overrides when it can see the event. Always pass it.
What not to do
Don't touch bindings at module scope. This looks fine and is broken:
// server/utils/db.ts — DO NOT DO THIS
const db = useCloudflareEnv().DB // no request, no env
Module initialization happens once per isolate, before any request exists. Pass the event (or the env) down into your data layer instead. If you're using Drizzle, that means constructing the client per request — it's cheap, since there's no connection to open:
export function useDb(event: H3Event) {
return drizzle(cf(event).env.DB, { schema })
}
Don't cache request-scoped values in module-level variables. Isolates are reused across requests from different users. A module-scoped let currentUser is a data leak.
Don't reach for KV as a database. KV is eventually consistent — a write can take up to about a minute to be visible everywhere, and reads are cached at the edge with a 60-second minimum TTL. It's excellent for config, feature flags, sessions, and rendered-fragment caches. It's wrong for anything read-after-write. That's D1 (relational, one primary region, optional read replicas) or a Durable Object (strongly consistent, single-threaded per instance).
A working checklist
nitro.preset = 'cloudflare_module',nodejs_compaton,assetsbinding declared.- Bindings declared in
wrangler.jsonc;wrangler typesrun and committed to the type-check step. nitro-cloudflare-devinmodules,.wranglergitignored.- One
cf(event)helper; no module-scope binding access. - Separate
--localand--remotemigration scripts. useRuntimeConfig(event)everywhere, always with the event.
Get those six right and the rest of the Workers platform — queues, cron triggers, Durable Objects, R2 — is just another entry in the same config file and another property on the same env object.
Last updated