Next.js
Run Lunora alongside Next.js on Cloudflare with the two-worker split — RSC loaders that hydrate into live subscriptions.
Last updated:
Next.js is supported through a two-worker split: Next.js deploys to
Cloudflare via the OpenNext adapter and owns page rendering, while a standalone
Lunora worker owns /_lunora/* and the Durable Objects. A React Server
Component resolves data during the server render, and the client hydrates that
result into a live subscription — so your RSC load is live.
lunora init my-app -t nextWhy two workers
OpenNext (@opennextjs/cloudflare) emits its own worker entry
(.open-next/worker.js) and owns it. There is no supported hook to compose extra
routes or Durable Object classes into that output, so /_lunora/* cannot ride
inside the Next worker.
| Worker | Config | Owns |
|---|---|---|
| Next.js SSR | wrangler.jsonc | Pages, RSC renders, route handlers |
| Lunora realtime | wrangler.lunora.jsonc | /_lunora/* RPC + WebSocket, ShardDO |
The Lunora worker is identical in shape to the standalone template — a
lunora/server.ts entry that exports ShardDO:
{
"name": "my-app-lunora",
"main": "lunora/server.ts",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "SHARD", "class_name": "ShardDO" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ShardDO"] }],
}Wiring them together
NEXT_PUBLIC_LUNORA_URL points the Next app at the Lunora worker. It must be
set at build time — Next inlines NEXT_PUBLIC_* into the client bundle — and
it configures both halves: the RSC loader reaches /_lunora/rpc at that URL, and
the browser client opens its WebSocket there.
Client and server entry points
Every hook in @lunora/react owns a live WebSocket and calls
useState/useEffect, so the hooks are client-only and each module declares
"use client". Server-side loading lives in a separate, server-safe entry:
| Import | Use from |
|---|---|
@lunora/react | Client Components |
@lunora/react/server | Server Components, route handlers |
@lunora/react/server opens no socket and touches no browser globals.
Loading data in a Server Component
The recommended flow is preloadQuery → usePreloadedQuery. The server runs the
query and returns a serializable token; the token crosses the RSC boundary as a
plain object, so there is no second network call on the client.
import { createServerClient, preloadQuery } from "@lunora/react/server";
import { cookies } from "next/headers";
import { api } from "@/lunora/_generated/api";
import { MessageFeed } from "@/components/message-feed";
export default async function Page() {
const client = createServerClient({
url: process.env.NEXT_PUBLIC_LUNORA_URL!,
token: (await cookies()).get("session")?.value,
});
const preloaded = await preloadQuery(client, api.messages.list, {});
return <MessageFeed preloaded={preloaded} />;
}"use client";
import type { Preloaded } from "@lunora/react";
import { usePreloadedQuery } from "@lunora/react";
import type { Doc } from "@/lunora/_generated/dataModel";
export function MessageFeed({ preloaded }: { preloaded: Preloaded<Doc<"messages">[]> }) {
// Seeds TanStack Query's cache synchronously — no loading flash — then
// attaches a live subscription that updates on every server delta.
const messages = usePreloadedQuery(preloaded);
return (
<ul>
{messages?.map((message) => (
<li key={message._id}>{message.text}</li>
))}
</ul>
);
}Forwarding the request cookie is what makes the server load run as the signed-in user, so RLS applies on the server render the same way it does on the client.
The prefetch alternative
If you would rather hydrate the whole TanStack cache than pass an explicit token,
prefetchQuery plus dehydrate and HydrationBoundary does that — see
@lunora/react.
For a one-shot read with no cache seeding and no subscription — a route handler,
a metadata function — use fetchQuery, fetchMutation, or fetchAction from
the same entry.
Providing the client
The hooks resolve their client from LunoraProvider, so a client boundary has to
own a browser LunoraClient and provide it to the tree:
"use client";
import { LunoraProvider } from "@lunora/react";
import { LunoraClient } from "lunorash/client";
import type { ReactNode } from "react";
import { useState } from "react";
// Fall back to the wrangler dev port in development only. In production an
// unset value must fail loudly — a localhost fallback would silently point
// every visitor's browser at their own machine.
const url = process.env.NEXT_PUBLIC_LUNORA_URL ?? (process.env.NODE_ENV === "development" ? "http://localhost:8787" : undefined);
if (!url) {
throw new Error("NEXT_PUBLIC_LUNORA_URL must be set — it is inlined at build time.");
}
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(() => new LunoraClient({ url }));
return <LunoraProvider client={client}>{children}</LunoraProvider>;
}Then wrap the app in it:
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Creating the client inside useState keeps one instance per browser session
rather than a new one per render. LunoraProvider also installs a TanStack
QueryClient tuned for Lunora's push-driven model, which is what
usePreloadedQuery seeds. The socket opens lazily on the first subscription, so
constructing the client here keeps it strictly browser-side — exactly what the
RSC handoff wants: the server fetches over HTTP, the live feed attaches after
hydration.
Developing
Two processes, because there are two workers:
# Terminal 1 — the Lunora worker (RPC + WebSocket + ShardDO)
pnpm dev:lunora # wrangler dev --config wrangler.lunora.jsonc
# Terminal 2 — Next.js
NEXT_PUBLIC_LUNORA_URL=http://localhost:8787 pnpm devpnpm dev runs lunora codegen and then next dev. The default in the
scaffolded code already matches wrangler's default port 8787, so with defaults
you can start both as-is.
The Vite plugin's single-process dev server does not apply here — Next.js owns its own dev server, so codegen runs as a build step rather than in watch
mode. Re-run lunora codegen after changing your schema or functions.
Deploying
Order matters: the Lunora worker's URL has to exist before the Next build, which inlines it.
# 1. Deploy the Lunora worker and note its URL
pnpm deploy:lunora
# → https://my-app-lunora.workers.dev
# 2. Build and deploy the Next.js worker with that URL
NEXT_PUBLIC_LUNORA_URL=https://my-app-lunora.workers.dev pnpm deploy:nextpnpm deploy runs both in that order. Preview the Cloudflare build locally with
pnpm preview (opennextjs-cloudflare build && opennextjs-cloudflare preview).