nuxtcloudflareworkersd1kv · 6 min read

Nuxt 3 on Cloudflare Workers: getting bindings right

How to wire KV, D1, and R2 bindings into Nuxt 3 server routes on Cloudflare Workers — typed env, local Miniflare dev, and the mistakes that break production.
An isometric illustration of a glowing server block with three colored storage modules plugged into it by streams of light on a dark grid background.

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

  1. nitro.preset = 'cloudflare_module', nodejs_compat on, assets binding declared.
  2. Bindings declared in wrangler.jsonc; wrangler types run and committed to the type-check step.
  3. nitro-cloudflare-dev in modules, .wrangler gitignored.
  4. One cf(event) helper; no module-scope binding access.
  5. Separate --local and --remote migration scripts.
  6. 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