Skip to content
DocsframeworksDocumentation

Reactive loaders

Your loaders are live — SSR data that hydrates into a real-time subscription with no flash.

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.

Bring your framework. Your loaders are live.

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 write

Everything 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 intermediate undefined/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 in useEffect after mount is the only freshness signal; when a mutation broadcasts a delta, usePreloadedQuery updates via queryClient.setQueryData and 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 cookie header from the request and passes a cookie-forwarding fetch to createServerClient. Every HTTP RPC the SSR client makes to /_lunora/rpc carries 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.