Last updated:
Every meta-framework has a route loader: Next.js Server Components, TanStack
Start createServerFn, React Router loader, SvelteKit load. They all do the
same thing: fetch data on the server, ship it in the HTML, hydrate it on the
client. And then they stop. The data is a snapshot, correct at request time
and stale the moment anyone else writes.
Lunora changes the second half. A Lunora loader fetches on the server and hands the client a token that re-opens the same query as a live subscription. The first paint is the SSR value, with no loading spinner and no flash; every write after that re-renders the component automatically.
The problem with static loaders
- Next / TanStack / React Router render server data well, but it's a one-shot
fetch. Keeping it fresh means polling, manual
revalidate, or wiring your own WebSocket on top, none of which the loader knows about. - void plugs any framework into one worker, but its loaders are static fetches. It has no reactivity layer; nothing keeps the rendered data in sync after hydration.
Lunora's loader and its real-time subscription are the same query. The server runs it once for SSR; the client resumes it as a subscription. There is no second data path, no polling, and no hand-rolled socket.
The handoff
[server: route loader] [client: framework adapter]
client = createServerClient({ url, fetch }) usePreloadedQuery(preloaded)
preloaded = await preloadQuery(client, fn, args) → seed initialData (no refetch)
return { preloaded } ──serialize──▶ → open WS subscription
→ re-render on every writeEverything left of "bind to UI" is framework-neutral: the same
createServerClient / preloadQuery flow regardless of which meta-framework
runs the loader. Only the last step (read the token, seed the cache, attach the
socket) is per-framework, and that's what each adapter provides.
Server: preload the query
The server half lives in @lunora/react/server, a module with no "use client"
directive, no WebSocket, and no browser globals, so it is safe to import from any
SSR loader or React Server Component. createServerClient builds a request-scoped
HTTP client; preloadQuery runs the query and returns a serializable Preloaded
token.
// A TanStack Start route loader (createServerFn).
import { createServerClient, preloadQuery } from "@lunora/react/server";
import { api } from "../../lunora/_generated/api";
const loadMessages = createServerFn().handler(async ({ request }) => {
// Forward the browser's cookie so the SSR query runs as the signed-in user.
const cookie = request.headers.get("cookie") ?? undefined;
const cookieForwardingFetch: typeof fetch = (input, init) => {
const headers = new Headers(init?.headers);
if (cookie) headers.set("cookie", cookie);
return fetch(input, { ...init, headers });
};
const client = createServerClient({ fetch: cookieForwardingFetch, url: workerUrl });
const preloaded = await preloadQuery(client, api.messages.list, { channelId }, { shardKey: channelId });
return { preloaded };
});The Preloaded token is a plain JSON-serializable object
({ __lunoraPreloaded: true, functionPath, args, shardKey?, value }), so the
framework's router dehydration embeds it directly in the SSR HTML inside a
<script> tag. No special serializer is needed.
Client: hydrate, then subscribe
On the client, usePreloadedQuery (from @lunora/react) reads the token, paints
the SSR value on the first render, and attaches the live subscription after mount.
import { useMutation, usePreloadedQuery } from "@lunora/react";
function ChannelView() {
const { preloaded } = Route.useLoaderData();
const messages = usePreloadedQuery(preloaded); // SSR value first, then live
const { mutate: send } = useMutation(api.messages.send);
return (
<ul>
{messages?.map((m) => (
<li key={m._id}>{m.text}</li>
))}
</ul>
);
}There is no loading state on the first paint: messages is the real list, and
it updates on every write from any client, with no extra wiring. It is typed
T | undefined all the same (and so is every adapter's hydratePreloaded):
after a sign-out or user switch the preloaded value, read for the previous
identity, is dropped until the new identity's subscription answers, hence the
?..
Why there's no flash
The no-flash behaviour is two TanStack Query options working together:
initialData: preloaded.value. The first render returns the SSR value synchronously. There is no intermediateundefined/loading state, so the server markup and the first client markup match (no hydration mismatch).staleTime: Infinity. Lunora is push-driven, so the seeded value is never considered stale and is never refetched. The WebSocket subscription that attaches inuseEffectafter mount is the only freshness signal; when a mutation broadcasts a delta,usePreloadedQueryupdates viaqueryClient.setQueryDataand React re-renders.
The first paint already contains live data, so the transition from server-rendered to live-subscribed is invisible: nothing refetches, no spinner appears, and the content does not shift.
Identity continuity (SSR → client)
The loader and the client subscription must run as the same user. On the same origin this is automatic:
- Server side, the loader reads the
cookieheader from the request and passes a cookie-forwardingfetchtocreateServerClient. Every HTTP RPC the SSR client makes to/_lunora/rpccarries the same session cookie the browser sent, so the worker's auth middleware resolves the same identity. - Client side, the browser naturally sends that same cookie on the WebSocket
upgrade to
/_lunora/ws. The subscription resumes the same identity with no token-exchange step.
So for same-origin, same-session apps you don't manage any tokens: cookie
forwarding on the server and the browser's own cookie on the socket are enough.
Apps that deploy the SSR layer and the worker on different origins (for
example a separate edge-SSR tier) pass the token option to createServerClient
instead, and the client provider is configured with the matching bearer token.
Sharding carries through
preloadQuery accepts a { shardKey } option, which it writes into the
Preloaded token. On the client, usePreloadedQuery reads preloaded.shardKey
and opens the subscription to the same Durable Object shard the loader read
from. For route-param-driven sharding (/rooms/:roomId), pass roomId as the
shardKey in the loader, and shard continuity from SSR to client is then automatic.
const preloaded = await preloadQuery(client, api.messages.list, { roomId }, { shardKey: roomId });Status
The reactive-loader contract is shipped for React today via @lunora/react
(createServerClient, preloadQuery, usePreloadedQuery) and proven end-to-end
on the TanStack Start template (lunora init -t tanstack-start-react).
A framework-neutral server entrypoint, @lunora/client/ssr (promoting
createServerClient / preloadQuery plus a getServerSession cookie helper),
backs each adapter's /server entry, with Solid, Svelte, Vue, and Angular
adapters that expose the same hydrate-then-subscribe handoff idiomatically. See
Bring your framework for the
per-framework status.