Skip to content
DocsframeworksDocumentation

Nuxt

Run Lunora and Nuxt as one Cloudflare Worker — the @lunora/nuxt module mounts /_lunora/** into Nitro, plus reactive-loader server helpers.

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/vue

Setup

Register the module and target Nitro's Cloudflare module preset:

nuxt.config.ts
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:

worker.ts
export { default } from "./.output/server/index.mjs";
export { ShardDO } from "./lunora/server";
wrangler.jsonc
{ "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

OptionDefaultDescription
appEntry~/lunora/serverModule 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.

nuxt.config.ts
export default defineNuxtConfig({
    modules: ["@lunora/nuxt"],
    lunora: { appEntry: "~/server/lunora-app" },
});

How it works

  • The route (addServerHandler at /_lunora/**) reconstructs a Web Request from the H3 event, resolves the Cloudflare env/ExecutionContext off it, and forwards to your app's fetch. A missing Cloudflare runtime answers a clear 500.
  • The #lunora/app alias points the route's worker import at your app entry, forwarded into the Nitro server bundle.
  • ShardDO rides to the deployed worker through your root worker.ts wrapper (wrangler.jsonc's main), which re-exports Nitro's SSR handler and ShardDO.

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.

plugins/lunora.ts
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:

server/api/posts.ts
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, {}) };
});
pages/posts.vue
<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:

  1. WebSocket upgrade pass-through. The live feed needs Nitro to return your Lunora app's 101 Switching Protocols response (carrying its Cloudflare webSocket) untouched. RPC (plain JSON) works regardless; if live subscriptions never connect while RPC does, Nitro is normalising the upgrade response.
  2. The worker.ts wrapper. wrangler.jsonc's main must point at a root worker.ts that re-exports Nitro's SSR handler and ShardDO. If wrangler deploy fails with "ShardDO class not exported", check that main points at the wrapper. The module warns when worker.ts is missing but can't verify wrangler's main points at it.

See Deploy your framework.

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_module preset) 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 root wrangler.jsonc — that is the file lunora verify|deploy|dev read, and they require the SHARD binding.
  • 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.