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
@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
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.
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.
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.
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
Edge adapter
createEdgeAdapter turns an EdgeRequest into an EdgeResponse. It handles redirects, HEAD/OPTIONS, CSP per-request, ETag, and an optional HTML cache.
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
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
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.
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.
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).
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.
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.
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
| Mode | Cold TTFB | Time-to-interactive | Best for |
|---|---|---|---|
| SPA (client-only) | Fast — static HTML shell | Slow on big bundles | Authed dashboards, internal tools |
renderRouteToString | Slower (full render before flush) | Fast — hydrate one tree | Marketing pages, SEO-critical routes |
renderRouteToStream | Fast (flush shell, defer suspense) | Fast progressively | Above-the-fold-heavy pages with data fetch |
staticExport | CDN-fast | Fast — pure HTML | Docs, 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.
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.
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.
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 swapISR — 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).
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 keyComplements build-time revalidateStaticPages (on-demand static regeneration) and the ETag/304 flow. Full PPR (streaming static shell + dynamic holes) is on the roadmap.
