Last updated:
@lunora/nuxt is a Nuxt module that runs Lunora and Nuxt as a single
Cloudflare Worker. Instead of Lunora owning the worker entry, it is mounted
inside Nitro: the module registers a server route at /_lunora/** that
forwards every Lunora RPC, WebSocket upgrade, and admin request to your Lunora
app in-process. One wrangler.jsonc, one deploy, a same-origin client.
Nuxt is Vue, so the reactive layer is @lunora/vue
(useQuery, useMutation, hydratePreloaded); @lunora/nuxt owns only the
server-side composition. This is the Nuxt-specific companion to
Bring your framework (Nuxt is a
class-B framework there) and
Reactive loaders; for the full reference
see @lunora/nuxt.
Preview. Class-B single-worker composition is preview maturity; run the verify before deploy checks below before shipping.
Install
pnpm add @lunora/nuxt @lunora/vueSetup
Register the module and target Nitro's Cloudflare module preset:
export default defineNuxtConfig({
modules: ["@lunora/nuxt"],
nitro: { preset: "cloudflare_module" },
});Nitro's cloudflare_module output exports only the SSR handler, so the
deployed worker needs a wrapper that also exports the ShardDO Durable Object
class. Add a worker.ts at the project root and point wrangler.jsonc's
main at it, not at the raw .output/server/index.mjs, or wrangler deploy
fails on the missing DO class:
export { default } from "./.output/server/index.mjs";
export { ShardDO } from "./lunora/server";{ "main": "worker.ts" }lunora/server.ts is your built Lunora app (defineApp().build()): its
default export is the worker (a fetch entrypoint), and it re-exports
ShardDO. The module aliases the #lunora/app virtual to it and serves it at
the /_lunora/** route.
Options
| Option | Default | Description |
|---|---|---|
appEntry | ~/lunora/server | Module specifier of the Lunora app entry (#lunora/app). |
The /_lunora/** mount is fixed: the worker routes on those exact paths and the
generated client calls them.
export default defineNuxtConfig({
modules: ["@lunora/nuxt"],
lunora: { appEntry: "~/server/lunora-app" },
});How it works
- The route (
addServerHandlerat/_lunora/**) reconstructs a WebRequestfrom the H3 event, resolves the Cloudflareenv/ExecutionContextoff it, and forwards to your app'sfetch. A missing Cloudflare runtime answers a clear 500. - The
#lunora/appalias points the route's worker import at your app entry, forwarded into the Nitro server bundle. ShardDOrides to the deployed worker through your rootworker.tswrapper (wrangler.jsonc'smain), which re-exports Nitro's SSR handler andShardDO.
Provide the client
Nuxt renders your app on the server as well as in the browser, and every
@lunora/vue composable resolves its client through
useLunora() — including inside renderToString. So the plugin that provides
it must be universal, not lunora.client.ts: a client-only plugin leaves
the server render with no provider, and the first SSR'd page that touches
Lunora throws useLunora(): no LunoraClient provided.
import { LunoraClient } from "lunorash/client";
import { createLunora } from "@lunora/vue";
export default defineNuxtPlugin((nuxtApp) => {
// Prod: same-origin. `useRequestURL()` resolves the request origin during
// SSR and `window.location` in the browser. The client appends
// `/_lunora/ws` and `/_lunora/rpc` itself, which the module's route serves.
//
// Dev: `nuxt dev` runs Nitro under Node, which can't host `ShardDO`, so
// `lunora dev` runs a `wrangler dev` sidecar that owns it — point the
// client straight at that instead. Match the port to your
// `wrangler.dev.jsonc` `dev.port` (the scaffold pins 8788).
const url = import.meta.dev ? "http://localhost:8788" : useRequestURL().origin;
nuxtApp.vueApp.use(createLunora(new LunoraClient({ url })));
});Providing the client server-side opens nothing: every composable gates its
WebSocket on a browser window, so the socket still attaches after hydration
while SSR renders the preloaded seed.
@lunora/nuxt warns at build time when the only plugin providing a client is
a .client.ts one, so an app written against an older version of this page
finds out from nuxt dev rather than from a 500. The module deliberately does
not register the provider itself — it cannot know your dev sidecar's origin,
and a second client would contend with yours over one durable outbox.
Reactive loaders
@lunora/nuxt/server re-exports the framework-neutral SSR helpers
(createServerClient, preloadQuery, …) from @lunora/client/ssr. They open
no WebSocket and touch no browser globals, so they are safe to import from a
Nitro server route. Preload on the server, hand the serializable token to the
page, and seed a live ref with @lunora/vue's hydratePreloaded:
import { createServerClient, preloadQuery } from "@lunora/nuxt/server";
import { api } from "../../lunora/_generated/api";
export default defineEventHandler(async () => {
// Per request — never reuse a client across requests (token leakage).
const client = createServerClient({ url: process.env.LUNORA_URL!, token });
return { preloaded: await preloadQuery(client, api.posts.list, {}) };
});<script setup lang="ts">
import { hydratePreloaded } from "@lunora/vue";
const { data } = await useFetch("/api/posts");
// Seeded from SSR on first read, then live.
const posts = hydratePreloaded(data.value!.preloaded);
</script>See Reactive loaders for the full handoff.
Feature flags
@lunora/nuxt ships no flag composable of its own. Read ctx.flags
server-side (a Nitro route, a function, or a reactive loader) and pass the
resolved value down, or call useFlag / useFlags from @lunora/vue
directly in a component for live updates over the WebSocket. Requires
@lunora/flags wired in lunora/flags.ts.
Verify before deploy
Single-worker composition rides on two Nitro behaviours that vary across versions, so verify them on your pinned toolchain:
- WebSocket upgrade pass-through. The live feed needs Nitro to return
your Lunora app's
101 Switching Protocolsresponse (carrying its CloudflarewebSocket) untouched. RPC (plain JSON) works regardless; if live subscriptions never connect while RPC does, Nitro is normalising the upgrade response. - The
worker.tswrapper.wrangler.jsonc'smainmust point at a rootworker.tsthat re-exports Nitro's SSR handler andShardDO. Ifwrangler deployfails with "ShardDO class not exported", check thatmainpoints at the wrapper. The module warns whenworker.tsis missing but can't verify wrangler'smainpoints at it.
Alternative: two-worker split
The default @lunora/nuxt setup above mounts Lunora inside Nitro (one
worker, one deploy). If you'd rather run Lunora as a fully separate worker, for
independent scaling or independent deploys, split into two workers instead.
Both workers below still target Nitro's cloudflare_module preset (the only
one @lunora/nuxt's in-process mounting or this split supports); this is a
topology choice, not a way to reach an unsupported preset:
- The Nuxt/Nitro SSR worker (
wrangler.nuxt.jsonc,cloudflare_modulepreset) handles all pages and server routes. - A separate standalone Lunora worker (the root
wrangler.jsonc,lunora/server.ts) owns/_lunora/*+ShardDO. Keep the Lunora worker on the rootwrangler.jsonc— that is the filelunora verify|deploy|devread, and they require theSHARDbinding. runtimeConfig.public.lunoraUrl(NUXT_PUBLIC_LUNORA_URL) tells the SSR loader and the browser client where to reach the Lunora worker.
This trades the same-origin convenience of the mounted route for two independently deployed workers.