| 1 | # @alchemy.run/frontend-frameworks/nuxt |
| 2 | |
| 3 | Wrangler-free Nuxt integration: programmatic build and dev for Nuxt projects, |
| 4 | with the deploy platform passed in as a **deploy target** value. Cloudflare |
| 5 | Workers is the built-in target (`@alchemy.run/frontend-frameworks/nuxt/cloudflare`); the |
| 6 | framework half of this package contains no Cloudflare code at all. |
| 7 | |
| 8 | ## Architecture: framework half × target half |
| 9 | |
| 10 | The package follows the framework × deploy-target split defined in |
| 11 | `@alchemy.run/frontend-frameworks/core` (see its README for the full `DeployTarget` |
| 12 | contract): |
| 13 | |
| 14 | | Module | Role | |
| 15 | | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 16 | | `@alchemy.run/frontend-frameworks/nuxt` (`src/nuxt/Nuxt.ts`, `src/nuxt/index.ts`) | **Framework half.** Drives the PROJECT's `@nuxt/kit` programmatically (loaded via the universally-resolvable `nuxt/kit` subpath): `loadNuxt` for build and dev, hook registration, nitro output mapping onto framework-core's `BuildOutput`, and the `Framework` service implementation. Target-agnostic — zero Cloudflare imports. | |
| 17 | | `@alchemy.run/frontend-frameworks/nuxt/cloudflare` (`src/nuxt/cloudflare.ts`) | **Target half.** The Cloudflare Workers `NuxtTarget`: nitro's `cloudflare_module` preset, the wrangler-free nitro config enforcement (`deployConfig: false`, `nodeCompat: true`), the user-entry seam (`main` → nitro's entry), and the proxy-backed dev platform (`src/nuxt/dev/host.ts`). | |
| 18 | | `@alchemy.run/frontend-frameworks/nuxt/source` (`src/nuxt/source.ts`) | Alchemy Worker **source provider** (structural `WorkerSourceModule` contract): maps the Nuxt build onto alchemy's bundle/assets/hash slots, plus a rebuild-free memo hash and nitro-dev `dev()`. Cloudflare-specific by nature; it passes the cloudflare target factory to the framework directly. | |
| 19 | | `src/nuxt/dev/{host,plugin,shared}.ts` | The **dev transport**: host half opens cloudflare-runtime's platform proxy and injects connect info; worker half is a dev-only nitro plugin that reconstructs `event.context.cloudflare` inside nitro's dev SSR worker thread. | |
| 20 | |
| 21 | Background on why the integration is shaped this way: |
| 22 | |
| 23 | - **The project's `nuxt.config.ts` loads natively.** `loadNuxt({ cwd, dev, |
| 24 | ready: false, overrides })` resolves the user's config, layers, and modules |
| 25 | through c12 exactly as `nuxi` would. The integration's injection rides the |
| 26 | highest-priority `overrides` layer (`src/UserConfig.ts`); the user's file |
| 27 | stays authoritative for everything not named there. |
| 28 | - **Hooks register before `nuxt.ready()`.** With `ready: true` the hooks fire |
| 29 | inside `loadNuxt` itself and a late registration misses them, so the |
| 30 | package always loads with `ready: false`, registers `nitro:config` / |
| 31 | `nitro:init`, then calls `ready()` + `buildNuxt` — the same flow `nuxi` |
| 32 | drives. |
| 33 | - **The deploy target owns the nitro preset.** `nitro:config` gets the last |
| 34 | word on the keys the integration owns. Because the overrides layer would |
| 35 | silently shadow a user-set preset in the merged options, the user's raw |
| 36 | value is read from the project's own config layer |
| 37 | (`nuxt.options._layers[0].config`) and a foreign `nitro.preset` **fails the |
| 38 | build with an actionable error** (`findPresetConflict`) — the user's nitro |
| 39 | output would never be deployed. |
| 40 | - **The `cloudflare_module` output needs no finishing pass.** Nitro emits |
| 41 | self-contained workerd ESM: `.output/server/index.mjs` + chunks and |
| 42 | `.output/public` (prerendered pages and `_headers` merged in). The |
| 43 | `BuildOutput` is read straight from disk — `serverModules` entry-first |
| 44 | from `.output/server`, `clientDirectory` = `.output/public`. |
| 45 | |
| 46 | ### The `NuxtTarget` contract |
| 47 | |
| 48 | The framework half declares what any deploy target must provide |
| 49 | (`src/Nuxt.ts`): |
| 50 | |
| 51 | ```ts |
| 52 | interface NuxtTarget extends DeployTarget<NuxtTargetConfig> { |
| 53 | /** The nitro deployment preset this target builds with. */ |
| 54 | nitroPreset: string; |
| 55 | /** Last-word mutation of the resolved nitro config (`nitro:config`). */ |
| 56 | configureNitro?(nitroConfig: NitroConfigSlice, context: NuxtNitroContext): void; |
| 57 | /** Acquire the dev platform (scoped) and return the injection dev needs. */ |
| 58 | devPlatform?(context: NuxtDevPlatformContext): Effect<NuxtDevPlatform, DeployTargetError, Scope>; |
| 59 | } |
| 60 | ``` |
| 61 | |
| 62 | - `nitroPreset` — enforced on the resolved nitro config after every module |
| 63 | and layer has contributed. |
| 64 | - `configureNitro(nitroConfig, context)` — runs from the `nitro:config` hook; |
| 65 | `context.entry` carries the resolved user worker entry when one is |
| 66 | configured. |
| 67 | - `devPlatform(context)` — the target's dev story: acquire whatever serves |
| 68 | the platform environment in dev (scoped — the finalizer runs AFTER the dev |
| 69 | server closes, so the platform outlives the last in-flight request) and |
| 70 | return `{ nitroPlugins, runtimeConfig }` for the framework half to inject |
| 71 | through the `loadNuxt` overrides. When absent, `dev` runs the plain |
| 72 | framework dev server with no platform bridge. |
| 73 | |
| 74 | `Framework.build` runs: resolve target → (`target.build`? delegate wholesale) |
| 75 | → `loadNuxt` with the preset + user overrides → register hooks → `ready()` + |
| 76 | `buildNuxt` → read `.output` → `applyDeployTargetFinish` (a no-op for |
| 77 | cloudflare_module, but the contract is honored for targets that |
| 78 | post-process). `Framework.dev` runs `loadNuxt({ dev: true })` → hooks → |
| 79 | `ready()` → `nuxt.server.listen` → `buildNuxt` (the same flow `nuxi dev` |
| 80 | drives), with a bounded readiness probe before returning the URL. |
| 81 | |
| 82 | ### The Cloudflare target |
| 83 | |
| 84 | `makeCloudflareTarget(config)` (default export of |
| 85 | `@alchemy.run/frontend-frameworks/nuxt/cloudflare`) implements the contract: |
| 86 | |
| 87 | - **`nitroPreset`** — `"cloudflare_module"`. |
| 88 | - **`configureNitro`** — wrangler-free doctrine: `cloudflare.deployConfig: |
| 89 | false` (nitro must never write a `wrangler.json` into user projects) and |
| 90 | `cloudflare.nodeCompat: true` by default (without a wrangler config on |
| 91 | disk nitro would otherwise skip its hybrid workerd node-compat; the |
| 92 | deployed worker enables the `nodejs_compat` flag to match). |
| 93 | - **The user-entry seam** — a configured `main` becomes nitro's `entry`, so |
| 94 | the user module's exports are the worker's exports. The entry is set on |
| 95 | the nitro INSTANCE at `nitro:init`, not in the config: nitro's prerenderer |
| 96 | clones `options._config` for its Node-preset sub-build, and a |
| 97 | workerd-flavored user entry (importing `cloudflare:` modules) must not |
| 98 | leak into that clone. A user entry wraps nitro's runtime handler and adds |
| 99 | its own exports (Durable Object classes, ...): |
| 100 | |
| 101 | ```ts |
| 102 | // worker-entry.mjs |
| 103 | import nitroHandler from "nitropack/presets/cloudflare/runtime/cloudflare-module"; |
| 104 | export { MyDurableObject } from "./durable-object.mjs"; |
| 105 | export default nitroHandler; |
| 106 | ``` |
| 107 | |
| 108 | - **`devPlatform`** — the wrangler-free dev bridge (next section). |
| 109 | |
| 110 | ## The dev transport |
| 111 | |
| 112 | Dev runs Nuxt's own dev server (nitro dev, SSR in a Node worker thread, full |
| 113 | HMR) with the `cloudflare_module` runtime contract served wrangler-free. The |
| 114 | transport has two halves sharing one contract (`src/dev/shared.ts`): |
| 115 | |
| 116 | - **Host half (`src/dev/host.ts`)** — runs in the alchemy / e2e-harness |
| 117 | process. It opens cloudflare-runtime's platform proxy (`getPlatformProxy`, |
| 118 | our wrangler-free reimplementation of wrangler's API: a local workerd |
| 119 | instance hosting the configured binding hooks) and produces the |
| 120 | `NuxtDevPlatform` injection: the dev-only nitro plugin path plus a |
| 121 | `runtimeConfig` entry carrying the `DevConnectInfo` — the proxy's `url` and |
| 122 | `token` (two plain strings), the absolute path of cloudflare-runtime's |
| 123 | runtime-free client module, and the literal env overrides. |
| 124 | - **Worker half (`src/dev/plugin.ts`)** — a nitro plugin bundled into |
| 125 | nitro's dev SSR worker thread. It reads the connect info back out of |
| 126 | `useRuntimeConfig()` and reconstructs the platform over HTTP with |
| 127 | `platform-proxy/connect`. On every request it sets the same contract |
| 128 | `nitro-cloudflare-dev` provides and production serves natively: |
| 129 | `event.context.cf`, `event.context.waitUntil`, and |
| 130 | `event.context.cloudflare = { request, env, context }` — `env` is the live |
| 131 | proxied environment (binding calls round-trip to the host's workerd |
| 132 | instance, so state is SHARED with the host proxy). |
| 133 | |
| 134 | Because the client state is just `{ url, token }`, the worker-thread hop is |
| 135 | trivial: `runtimeConfig` strings survive nitro's bundling and worker |
| 136 | replacement, so the plugin reconnects on its own after every dev reload |
| 137 | while binding state (KV, DO storage, ...) stays live in the host's workerd |
| 138 | instance. The connection is established lazily on the first request and |
| 139 | re-created after a failure; when the host proxy is gone, requests fail fast |
| 140 | with a descriptive cause instead of hanging. |
| 141 | |
| 142 | Injected plugins live under `node_modules` (this package's dist), so their |
| 143 | directories are forced into `nitro.externals.inline` — nitro's externals |
| 144 | pass would otherwise keep the plugin external and node would resolve its |
| 145 | `nitropack/runtime` import against the raw package, whose |
| 146 | `#nitro-internal-virtual/*` imports only exist inside the dev bundle. |
| 147 | |
| 148 | ## Usage |
| 149 | |
| 150 | ### With the e2e harness (`e2e.config.ts`) |
| 151 | |
| 152 | ```ts |
| 153 | import * as Options from "@alchemy.run/cloudflare-test-tools/e2e/Options"; |
| 154 | import * as Nuxt from "@alchemy.run/frontend-frameworks/nuxt"; |
| 155 | |
| 156 | export default Options.make({ |
| 157 | // string form: framework: "@alchemy.run/frontend-frameworks/nuxt" |
| 158 | framework: (options) => Nuxt.layer(Nuxt.fromHarnessOptions(options as Nuxt.HarnessOptions)), |
| 159 | target: { |
| 160 | cloudflare: { |
| 161 | worker: { |
| 162 | compatibilityDate: "2026-03-10", |
| 163 | compatibilityFlags: ["nodejs_compat"], |
| 164 | main: "worker-entry.ts", |
| 165 | worker: { |
| 166 | bindings: [ |
| 167 | /* ... */ |
| 168 | ], |
| 169 | }, |
| 170 | }, |
| 171 | preview: { |
| 172 | /* miniflare options for `e2e preview` / Playwright "live" */ |
| 173 | }, |
| 174 | }, |
| 175 | }, |
| 176 | }); |
| 177 | ``` |
| 178 | |
| 179 | `fromHarnessOptions` reads the target-scoped carriage |
| 180 | (`target.cloudflare.worker`, falling back to the deprecated top-level `vite` |
| 181 | alias): compatibility date/flags, the user worker entry, and the declared |
| 182 | binding hooks, which pass through wholesale to the dev platform proxy — |
| 183 | resource bindings (KV, D1, …) included, not just literal values. |
| 184 | |
| 185 | ### Direct (typed) usage |
| 186 | |
| 187 | ```ts |
| 188 | import * as Nuxt from "@alchemy.run/frontend-frameworks/nuxt"; |
| 189 | |
| 190 | const layer = Nuxt.layer({ |
| 191 | compatibilityDate: "2026-03-10", |
| 192 | compatibilityFlags: ["nodejs_compat"], |
| 193 | main: "worker-entry.ts", |
| 194 | nuxt: { routeRules: { "/about": { prerender: true } } }, |
| 195 | dev: { |
| 196 | port: 3104, |
| 197 | // cloudflare-runtime binding hooks, served on event.context.cloudflare.env |
| 198 | bindings: [KvNamespace.local("MY_KV")], |
| 199 | // literal overrides — win over same-named proxied values |
| 200 | env: { MY_SECRET: "..." }, |
| 201 | }, |
| 202 | }); |
| 203 | ``` |
| 204 | |
| 205 | ### Selecting a deploy target |
| 206 | |
| 207 | `NuxtOptions.target` accepts a `DeployTargetInput`: |
| 208 | |
| 209 | ```ts |
| 210 | // 1. default — omitted: resolves "@alchemy.run/frontend-frameworks/nuxt/cloudflare" |
| 211 | // from the project's node_modules |
| 212 | Nuxt.layer({}); |
| 213 | |
| 214 | // 2. factory — applied to the config the framework assembles from options |
| 215 | import cloudflareTarget from "@alchemy.run/frontend-frameworks/nuxt/cloudflare"; |
| 216 | Nuxt.layer({ target: cloudflareTarget }); |
| 217 | |
| 218 | // 3. value — full control; options-level compat fields are ignored |
| 219 | Nuxt.layer({ target: cloudflareTarget({ compatibilityDate: "2026-03-10" }) }); |
| 220 | |
| 221 | // 4. specifier — loaded from the project's dependency tree |
| 222 | Nuxt.layer({ target: "@my-scope/nuxt-aws" }); |
| 223 | ``` |
| 224 | |
| 225 | A future target (e.g. AWS) implements `NuxtTarget` in its own module — no |
| 226 | change to this package's framework half. |
| 227 | |
| 228 | ## Options |
| 229 | |
| 230 | ### `NuxtOptions` (framework) |
| 231 | |
| 232 | | Option | Default | Purpose | |
| 233 | | -------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | |
| 234 | | `root` | `process.cwd()` | Project root (the directory containing `nuxt.config.ts`). | |
| 235 | | `target` | `"@alchemy.run/frontend-frameworks/nuxt/cloudflare"` | Deploy target: value, factory, or module specifier. | |
| 236 | | `compatibilityDate` | — | Forwarded to the target config (carried for serve/deploy consumers). | |
| 237 | | `compatibilityFlags` | `["nodejs_compat"]` (applied by the cloudflare target) | Forwarded to the target config. Nitro's workerd build uses hybrid Node compat, so `nodejs_compat` is effectively required. | |
| 238 | | `main` | — | The USER's worker entry (nitro's entry/exports seam). Relative paths resolve against the root. | |
| 239 | | `nuxt` | — | Nuxt config overrides merged over the project's `nuxt.config.ts` (integration wins). `nitro.preset` is always owned by the target. | |
| 240 | | `dev.port` | random | Dev-server port (overridden by `FrameworkDevOptions.port`). | |
| 241 | | `dev.bindings` | `[]` | Binding specs the target's dev platform serves (Cloudflare: cloudflare-runtime `BindingHooks`). | |
| 242 | | `dev.env` | `{}` | Literal env overrides on the dev platform — a same-named literal wins over the proxied binding value. | |
| 243 | |
| 244 | ### `NuxtSourceOptions` (`./source`, alchemy) |
| 245 | |
| 246 | `rootDir`, `memo` (`include`/`exclude` globs + `lockfile`, default |
| 247 | gitignore-aware), `nuxt`, `main` — all JSON-serializable (they persist in |
| 248 | alchemy state and participate in the Worker's metadata hash). `build()` maps |
| 249 | the nitro output onto alchemy's source contract (entry-first bundle files, |
| 250 | manifest-hashed assets honoring `.assetsignore`/`_headers`/`_redirects`, |
| 251 | project-tree `input` hash); `hash()` recomputes the input hash without |
| 252 | building; `dev()` starts nitro's dev server with the proxy-backed platform: |
| 253 | the Worker's binding hooks (`ctx.worker.bindings`) serve on |
| 254 | `event.context.cloudflare.env`, while literal `props.env` values (strings; |
| 255 | `Redacted` values unwrapped) apply as overrides. |
| 256 | |
| 257 | ## Limitations & known constraints |
| 258 | |
| 259 | - **Pinned upstream surfaces.** nuxt `4.5.x`, nitropack `2.13.x` (see |
| 260 | `packages/frontend-frameworks/fixtures/nuxt`); treat version bumps as deliberate migrations. Note the |
| 261 | repo's `minimumReleaseAge` install gate (3 days) when bumping. |
| 262 | - **`isr` route rule ignored.** Nitro implements `isr` only in the Vercel |
| 263 | and Netlify presets — on `cloudflare_module` it is silently dropped at |
| 264 | build time and the route renders on demand like any other SSR route. Use |
| 265 | `prerender` for build-time static routes or `cache` rules for runtime |
| 266 | caching. |
| 267 | - **Durable Objects are not servable in dev.** The platform proxy cannot |
| 268 | host user worker-entry classes — DOs declared via the entry/exports seam |
| 269 | only exist in the production build, so DO namespace bindings fail in dev. |
| 270 | - **Foreign `nitro.preset` is a hard error.** The deploy target owns the |
| 271 | preset; a user-configured preset fails the build with an actionable |
| 272 | message instead of being silently replaced. |
| 273 | - **Dev fidelity.** SSR runs in nitro's Node worker thread, not workerd — |
| 274 | forcing nitro's dev flow into workerd is a non-goal. The platform proxy's |
| 275 | documented limitations apply in dev: |
| 276 | - binding methods whose results are rich class instances (e.g. R2's |
| 277 | `R2Object`) are not supported over the proxy — results must be |
| 278 | JSON-compatible values, bytes, dates, streams, or `DurableObjectId`s; |
| 279 | - no synchronous materialisation of intermediate values — |
| 280 | `env.DO.idFromName("a").toString()` needs |
| 281 | `(await env.DO.idFromName("a")).toString()`; |
| 282 | - `connect()` on sockets is unsupported, and cross-binding stub arguments |
| 283 | are rejected. |
| 284 | - **`event.context.waitUntil` is accepted and dropped in dev** (long-lived |
| 285 | dev process: background work simply runs). Production serves the real |
| 286 | `ExecutionContext`. |
| 287 | - **`context.cloudflare.request` carries no body in dev.** The node request |
| 288 | stream belongs to h3's own body parsing — read bodies through h3 |
| 289 | (`readBody`), not `context.cloudflare.request`. |
| 290 | - **Telemetry/devtools are disabled in dev** — the dev server runs headless |
| 291 | under alchemy / the e2e harness. |
| 292 | |
| 293 | ## Testing |
| 294 | |
| 295 | - `bun run test` — unit tests: build hook wiring and output mapping |
| 296 | (`test/Nuxt.test.ts`), the target contract (`test/Target.test.ts`), |
| 297 | config synthesis and preset-conflict policy (`test/UserConfig.test.ts`), |
| 298 | and the dev transport — host platform acquisition, connect-info shape, |
| 299 | and the plugin's request bridge against a fake proxy server |
| 300 | (`test/Dev.test.ts`). |
| 301 | - `packages/frontend-frameworks/fixtures/nuxt` — the end-to-end fixture (Playwright against both the |
| 302 | miniflare-served production build and the nitro dev server); see its |
| 303 | layout for the covered surface (SSR routes, `server/` API routes, |
| 304 | prerendered pages, `event.context.cloudflare.env` with real bindings and |
| 305 | literal overrides, and the custom `worker-entry.ts` seam). |
| 306 | |
| 307 | ## Upstream references |
| 308 | |
| 309 | - Nuxt: [`17119f8ee6f753faf2e61564bba3211871e16e2c`](https://github.com/nuxt/nuxt/tree/17119f8ee6f753faf2e61564bba3211871e16e2c) |
| 310 | - Nitro: [`52abde8aa9d85215bc0e8dc3b007f29fc6112ac3`](https://github.com/nitrojs/nitro/tree/52abde8aa9d85215bc0e8dc3b007f29fc6112ac3) |
| 311 | - Nitro Cloudflare dev: [`a22a83b7151b314d70a01089c352ca6007161051`](https://github.com/nitrojs/nitro-cloudflare-dev/tree/a22a83b7151b314d70a01089c352ca6007161051) |