JORVEL
SSR

SSR & static export

@jorvel/ssr renders matched routes on the server, streams them to the browser, and hydrates with renderToString by default (so the React tree carries the markers needed for hydration). Edge runtimes use @jorvel/ssr/edge; Node uses @jorvel/ssr/node.

Bare import is edge-safe

The main @jorvel/ssr entry exports worker, edge-light, and browser conditions that resolve to a node:fs-free build. So a Workers / Vercel-Edge bundler that imports bare @jorvel/ssrwon't drag Node built-ins into the bundle — Node tooling still gets the full entry. You can keep using the explicit /edge and /node subpaths to be unambiguous.

renderRouteToString

apps/shell/server/render.ts
ts
import { renderRouteToString, injectIntoTemplate } from '@jorvel/ssr';
import { App } from '../src/App';

export async function renderShell(pathname: string, template: string) {
  const result = await renderRouteToString(App, { path: pathname });
  return {
    html: injectIntoTemplate(template, result.html),
    status: result.statusCode,
  };
}

Hydratable by default

renderRouteToString uses React's renderToString so the output carries hydration markers. renderToStaticMarkup is opt-in for purely static pages (404 markup, error shells).

Streaming SSR

For above-the-fold-first delivery, use renderRouteToStream. It pipes synchronously inside onShellReady, supports signal / timeoutMs / onError, and surfaces deferred Suspense errors via an errors[] array.

apps/shell/server/stream.ts
ts
import { renderRouteToStream, type StreamRenderResult } from '@jorvel/ssr';

export async function streamShell(req, res) {
  const ac = new AbortController();
  req.on('close', () => ac.abort());

  const result: StreamRenderResult = await renderRouteToStream(App, {
    path: req.url,
    signal: ac.signal,
    timeoutMs: 5000,
    onError: (err) => observability.captureException(err),
  });

  res.setHeader('content-type', 'text/html; charset=utf-8');
  result.pipe(res);
}

Streaming on edge runtimes (Web Streams)

Cloudflare Workers, Vercel Edge, and Deno Deploy do not provide node:stream. Import renderRouteToReadableStream or renderRouteToResponse from @jorvel/ssr/edge to get a Web ReadableStream<Uint8Array> backed by React 18's renderToReadableStream. Same options surface — signal, timeoutMs, bootstrapScripts, nonce, onError — and waitForAllReady for SEO crawlers that need the finished shell.

apps/shell/worker.ts
ts
import { renderRouteToResponse } from '@jorvel/ssr/edge';

export default {
  async fetch(req: Request) {
    return renderRouteToResponse(App, { path: new URL(req.url).pathname }, {
      bootstrapScripts: ['/static/app.js'],
      nonce: req.headers.get('x-csp-nonce') ?? undefined,
      signal: req.signal,
      timeoutMs: 5_000,
      onError: (err) => console.error(err),
    });
  },
};

Streaming remote fragments

For multi-remote pages, SSR each remote in parallel and stream their HTML in any order — the host shell flushes first with placeholders, and each fragment swaps in as it finishes. Matches the Cloudflare Fragments pattern.

apps/shell/worker.ts
ts
import { renderFragmentsToReadableStream } from '@jorvel/ssr/edge';

const shell = `<!doctype html><html><body>
  <header><jorvel-fragment name="nav" /></header>
  <main><jorvel-fragment name="cart" /></main>
  <footer><jorvel-fragment name="recs" /></footer>
</body></html>`;

const { stream, done } = renderFragmentsToReadableStream({
  shell,
  timeoutMs: 5_000,
  fragments: [
    { name: 'nav',  render: async () => fetch('https://nav.acme.dev/ssr').then((r) => r.text()),  fallback: '<nav/>' },
    { name: 'cart', render: async () => fetch('https://cart.acme.dev/ssr').then((r) => r.text()), fallback: '<aside/>' },
    { name: 'recs', render: async () => fetch('https://recs.acme.dev/ssr').then((r) => r.text()), fallback: '' },
  ],
  onFragment: (e) => observability.report('fragment', e),
});
done.then(({ outcomes }) => /* persist outcomes for SLO dashboards */ {});
return new Response(stream, { headers: { 'content-type': 'text/html; charset=utf-8' } });

Fallback first, fragment second

The stream variant inlines the fallback markup inside the placeholder before any fragment resolves. If a fragment times out or fails, the fallback stays on the page — the swap script never runs.

Edge adapter

createEdgeAdapter turns an EdgeRequest into an EdgeResponse. It handles redirects, HEAD/OPTIONS, CSP per-request, ETag, and an optional HTML cache.

worker.ts
ts
import { createEdgeAdapter, LruHtmlCache } from '@jorvel/ssr/edge';

const handler = createEdgeAdapter({
  App,
  template,
  routes,
  etag: true,
  htmlCache: new LruHtmlCache({ max: 256, ttlMs: 60_000 }),
  cache: { scope: 'public', maxAge: 0, sMaxAge: 60, staleWhileRevalidate: 600 },
  csp: (req) => buildCsp({ nonce: cryptoRandomNonce() }),
});

export default { fetch: (req: Request) => toResponse(handler(toEdgeRequest(req))) };

ETag before render

When htmlCache is set and a response is already cached for cacheKey(request), the adapter checks If-None-Match against the stored ETag before calling React. Hits return 304 with no render. Auto-disabled when enrichHead is set, or when loaders are present (per-request HTML).

Read-only by design

The adapter SSR-renders GET and HEAD only — any other method returns 405. Route mutations through your API, not the renderer.

Loaders in the edge adapter

Pass loaders to createEdgeAdapter and it runs the matched route loaders inside the request context before render — so useLoaderData() resolves during SSR. The collected data is serialized to window.__JORVEL_LOADER_DATA__for the client to hydrate from (no second fetch), and each loader's setHeader / cacheControl is merged into the response. Because the HTML is now request-specific, the adapter auto-disables the HTML cache whenever loaders are configured.

ts
import { createEdgeAdapter } from '@jorvel/ssr/edge';
import { userLoader } from './loaders';

const handler = createEdgeAdapter({
  App,
  template,
  routes,
  loaders: [userLoader],   // run before render; data → window.__JORVEL_LOADER_DATA__
});

Static export (SSG)

staticExport pre-renders a list of routes with bounded parallelism (default 8). It deduplicates output paths, blocks path traversal, and optionally writes a content-hash manifest.

ts
import { staticExport } from '@jorvel/ssr/node';

await staticExport({
  routes: [{ path: '/' }, { path: '/about' }],
  App,
  template,
  outDir: 'dist-ssg',
});

Redirects and response helpers

Throw redirect, json, or notFound from anywhere in the React tree. renderRouteToString re-throws each control-flow error so the edge adapter can map it to a real HTTP response (302, JSON 4xx/5xx, or the configured 404 page).

tsx
import { redirect, json, notFound } from '@jorvel/ssr';

export default function User({ params }: { params: { id: string } }) {
  if (!isLoggedIn()) throw redirect('/login', 302);

  const user = lookup(params.id);
  if (!user) throw notFound();                       // → 404
  if (user.banned) throw json({ error: 'banned' }, 403);

  return <Profile user={user} />;
}

Per-route data loaders

Co-locate data fetching with the route. defineLoader describes one loader; runLoaders runs a list of them concurrently before render. Components read results via useLoaderData — no second client fetch needed for hydration.

ts
import { defineLoader, runLoaders, useLoaderData, redirect } from '@jorvel/ssr';

export const userLoader = defineLoader({
  key: 'user',
  cacheControl: 'private, max-age=0',
  load: async ({ ctx, setHeader, params }) => {
    const sid = ctx?.cookies['sid'];
    if (!sid) throw redirect('/login', 302);
    setHeader('Vary', 'Cookie');
    return await fetchUser(params.id, sid);
  },
});

// In your handler — collect headers + cache-control before render.
const { data, headers, cacheControl } = await runLoaders({
  loaders: [userLoader],
  request,
  params: match.params,
});

// In your component
function UserPage() {
  const user = useLoaderData<User>('user');
  return <h1>{user?.name}</h1>;
}

Serve client assets alongside SSR

jorvel ssr serve --static <dir> serves the built client assets next to the SSR handler, so the hydration bundles referenced in the rendered HTML resolve in dev and in single-process deployments.

Per-request context

Components can read the inbound request via getRequestContext() / requireRequestContext(). The edge adapter brackets each render with runWithRequestContext, so concurrent requests are isolated.

tsx
import { requireRequestContext } from '@jorvel/ssr';

export default function Page() {
  const ctx = requireRequestContext();
  const locale = ctx.cookies['locale'] ?? 'en';
  return <Greeting locale={locale} />;
}

The context exposes url, method, lowercase headers, parsed cookies, and a free-form locals bag you can populate from middleware (user/session/locale).

When to pick which mode

ModeCold TTFBTime-to-interactiveBest for
SPA (client-only)Fast — static HTML shellSlow on big bundlesAuthed dashboards, internal tools
renderRouteToStringSlower (full render before flush)Fast — hydrate one treeMarketing pages, SEO-critical routes
renderRouteToStreamFast (flush shell, defer suspense)Fast progressivelyAbove-the-fold-heavy pages with data fetch
staticExportCDN-fastFast — pure HTMLDocs, blog, status pages

Safe state hydration

Use safeJsonForScript from @jorvel/security to inject server state. It escapes </script> sequences and validates nonces against the base64url alphabet.

ts
import { safeJsonForScript } from '@jorvel/security';

const head = `<script id="__jorvel_state" type="application/json" nonce="${nonce}">${
  safeJsonForScript({ user, flags })
}</script>`;

Server routes (API + tRPC/Hono)

A dependency-free API router adapters consult before SSR. Return null on no match to fall through to page rendering. Mount tRPC/Hono as a prefix fallback.

ts
import { createApiRouter, defineRoute, json } from '@jorvel/ssr';

const api = createApiRouter([
  defineRoute('GET', '/health', () => json({ ok: true })),
  defineRoute('GET', '/users/:id', ({ params }) => json({ id: params.id })),
], { prefix: '/api' });

// in your adapter handler:
const res = await api.handle(request);
return res ?? renderSSR(request);

// tRPC / Hono: mount their fetch handler as a fallback
createApiRouter([], { prefix: '/trpc', fallback: ({ request }) => trpcFetchHandler(request) });

Critical CSS

Inline above-the-fold CSS and defer the full sheet — a best-effort, dependency-free heuristic that keeps rules whose selectors appear in the rendered HTML.

ts
import { inlineCriticalCss } from '@jorvel/ssr';

const html = inlineCriticalCss(renderedHtml, fullCss, { href: '/assets/app.css' });
// → <style data-critical>…used rules…</style> in <head>, full sheet loaded via media=print swap

ISR — request-time regeneration

Serve cached HTML instantly and regenerate in the background when it goes stale (stale-while-revalidate). Bring any HtmlCache (in-memory default, or Redis/KV).

ts
import { serveWithISR, LruHtmlCache } from '@jorvel/ssr';

const cache = new LruHtmlCache();

const { html, status, cached, stale } = await serveWithISR({
  cache,
  key: url.pathname,
  revalidateMs: 60_000,                 // fresh for 60s, then background-regenerate
  render: () => renderRouteToString(App, { path: url.pathname }).then((html) => ({ html })),
});
// concurrent stale hits dedupe to ONE background render per key

Complements build-time revalidateStaticPages (on-demand static regeneration) and the ETag/304 flow. Full PPR (streaming static shell + dynamic holes) is on the roadmap.