JORVEL

Recipes / Cookbook

Copy-paste-able solutions built on JORVEL primitives. Each is intentionally small.

Auth.js / Lucia integration

JORVEL ships session + OAuth primitives (Authentication), but you can bring Auth.js or Lucia. Run the library's handler in a server route and bridge its session into a JORVEL SessionManager cookie (so middleware gating works).

ts
// Lucia validates its own cookie; mirror the user id into a JORVEL session so
// getSession()/requireUser() and middleware gates keep working.
import { SessionManager } from '@jorvel/security';
const sessions = new SessionManager<{ id: string }>({ secret: process.env.SESSION_SECRET! });

const { session, user } = await lucia.validateSession(sessionId);
if (session) {
  const setCookie = await sessions.seal({ id: user.id });
  // set both cookies on the response
}

SSO / SAML

For enterprise SSO, terminate SAML at an IdP-facing service (e.g. @node-saml/node-samlor WorkOS) and, on assertion success, issue a JORVEL session cookie. OIDC-based SSO uses the built-in OAuth/PKCE helpersdirectly with your IdP's endpoints.

ts
// /sso/callback — after the IdP POSTs a validated SAML assertion:
const profile = await saml.validatePostResponse(formData);      // your SAML lib
const setCookie = await sessions.seal({ id: profile.nameID, roles: profile.roles });
return new Response(null, { status: 302, headers: { location: '/', 'set-cookie': setCookie } });

Federation kill-switch / circuit breaker

Wrap remote loads so a failing remote is skipped (and a fallback UI shown) instead of taking the host down. Combine the resilience helper with a feature flag as a manual kill-switch.

ts
import { loadWithFallback, ResilientRemoteCache } from '@jorvel/runtime';
import { isEnabled } from '@jorvel/runtime'; // feature flags

async function loadRemote(remote) {
  if (!isEnabled('remote:' + remote.name)) throw new Error('killed'); // kill-switch
  return loadWithFallback({ remote, cache: new ResilientRemoteCache(), loader });
}
// Render a <RemoteOutlet noMatch={<Degraded/>} /> so a killed/broken remote degrades gracefully.

Mailer / queue / cron

Server actions + a DB (jorvel add db) cover most jobs. For email use Resend, for queues/cron use Upstash QStash or your platform's scheduler.

ts
import { defineAction } from '@jorvel/runtime';

export const sendWelcome = defineAction(async (input: { to: string }) => {
  await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: { authorization: 'Bearer ' + process.env.RESEND_API_KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ from: 'hi@app.com', to: input.to, subject: 'Welcome', html: '<p>Hi!</p>' }),
  });
});

// Cron: expose a route hit by Vercel Cron / Upstash QStash on a schedule.

Edge KV / Durable Objects

On Cloudflare, bindings arrive via the Worker env (see adapter-cloudflare's onRequest(env)). Use KV for read-mostly config and Durable Objects for coordination (also backs the distributed rate-limit stores).

ts
export default {
  fetch: createCloudflareHandler({
    App, template, routes,
    onRequest: async (req, env) => {
      const flags = await env.CONFIG_KV.get('flags', 'json'); // KV read
      // stash on request-context locals for loaders to read
    },
  }),
};

Dark mode toggle

Persist the theme in a SimpleStore (survives across remotes) and reflect it on <html data-theme>. Set it before hydration to avoid a flash.

tsx
import { getSimpleStore } from '@jorvel/state';
import { useSimpleStore } from '@jorvel/state/react';

const theme = getSimpleStore<'light' | 'dark'>('theme', 'light');

export function ThemeToggle() {
  const value = useSimpleStore(theme);
  return (
    <button onClick={() => {
      const next = value === 'dark' ? 'light' : 'dark';
      theme.set(next);
      document.documentElement.dataset.theme = next;
      localStorage.setItem('theme', next);
    }}>{value === 'dark' ? '☀️' : '🌙'}</button>
  );
}
// In index.html <head>, inline: document.documentElement.dataset.theme = localStorage.theme || 'light'

Design tokens / CSS variables

css
:root {
  --color-bg: #ffffff; --color-fg: #0a0a0a; --radius: 8px; --space: 4px;
}
:root[data-theme='dark'] { --color-bg: #0a0a0a; --color-fg: #fafafa; }
/* Consume in every remote — variables cross Shadow DOM boundaries. */
.card { background: var(--color-bg); color: var(--color-fg); border-radius: var(--radius); }

Ship tokens from a shared package so host + remotes reference the same names.

CSS-in-JS (vanilla-extract / Panda)

Prefer zero-runtime CSS-in-JS so styles don't double-load across remotes. vanilla-extract (@vanilla-extract/webpack-plugin works with Rspack) and Panda CSSboth compile to static CSS at build time. Add the plugin in each app's rspack.config.mjs; the extracted .css is shared like any asset.

tRPC / Hono server routes

The server-route convention (createApiRouter) mounts any fetch handler as a prefix fallback — so tRPC or Hono runs alongside JORVEL SSR.

ts
import { createApiRouter } from '@jorvel/ssr';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from './trpc/router.js';

const api = createApiRouter([], {
  prefix: '/trpc',
  fallback: ({ request }) =>
    fetchRequestHandler({ endpoint: '/trpc', req: request, router: appRouter, createContext: () => ({}) }),
});

// adapter handler: const res = await api.handle(request); return res ?? renderSSR(request);
// Hono: fallback: ({ request }) => honoApp.fetch(request)

Stripe checkout + webhook

A server action creates a Checkout Session; a server route verifies the webhook signature and fulfills. Keep the secret key server-only.

ts
import { defineAction } from '@jorvel/runtime';

export const checkout = defineAction(async (input: { priceId: string }) => {
  const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
    method: 'POST',
    headers: { authorization: 'Bearer ' + process.env.STRIPE_SECRET_KEY, 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ mode: 'payment', 'line_items[0][price]': input.priceId, 'line_items[0][quantity]': '1', success_url: 'https://app/ok', cancel_url: 'https://app/cancel' }),
  });
  return (await res.json()).url as string;   // redirect the browser here
});

// webhook route: verify `stripe-signature` (HMAC) before trusting the event, then fulfill.

AI chatbot (Vercel AI SDK)

Stream tokens from a server route with the Vercel AI SDK; render with useChat on the client. Anthropic Claude shown; swap the provider as needed.

ts
// server route: POST /api/chat
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

export async function POST(req: Request) {
  const { messages } = await req.json();
  const result = streamText({ model: anthropic('claude-sonnet-5'), messages });
  return result.toDataStreamResponse();      // streams to the client
}
tsx
// client
import { useChat } from '@ai-sdk/react';

function Chat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat({ api: '/api/chat' });
  return (
    <form onSubmit={handleSubmit}>
      {messages.map((m) => <p key={m.id}><b>{m.role}:</b> {m.content}</p>)}
      <input value={input} onChange={handleInputChange} />
    </form>
  );
}

AI SDK / LangChain.js

The Vercel AI SDK (ai + @ai-sdk/*) is the recommended default — provider-agnostic, streaming-first, pairs with server actions/routes. For agents/RAG, LangChain.js runs in the same server routes. Keep model API keys server-only (env), never in the client bundle.

React Compiler toggle

Set features.reactCompiler: true in jorvel.config.json, then add a babel pass for the compiler in each app's rspack.config.mjs (SWC handles the rest of the transform):

js
// rspack.config.mjs — run babel-plugin-react-compiler before swc-loader
{
  test: /\.[jt]sx$/,
  exclude: /node_modules/,
  use: [{ loader: 'babel-loader', options: { plugins: [['babel-plugin-react-compiler', { target: '18' }]] } }],
}
// deps: pnpm add -D babel-loader @babel/core babel-plugin-react-compiler

Optimistic form

Combine <Form> + useOptimistic for instant feedback:

tsx
import { Form, useOptimistic, defineAction } from '@jorvel/runtime';

const addComment = defineAction(async (fd: FormData) => api.post('/comments', fd));

function Comments({ list }: { list: Comment[] }) {
  const [optimistic, add] = useOptimistic(list, (cur, text: string) => [...cur, { id: 'temp', text, pending: true }]);
  return (
    <Form action={addComment} onSubmit={(e) => add(new FormData(e.currentTarget).get('text') as string)}>
      <ul>{optimistic.map((c) => <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>{c.text}</li>)}</ul>
      <input name="text" /><button>Post</button>
    </Form>
  );
}

Magic link: email a signed, expiring token (reuse the session HMAC), verify on click, then seal() a session. Passkey (WebAuthn): use navigator.credentials on the client + a verifier lib server-side, then issue the same JORVEL session.

ts
import { randomToken, hmacSign, hmacVerify, SessionManager } from '@jorvel/security';

// request: sign email+exp, email the link
const exp = String(nowSeconds + 900);
const sig = await hmacSign(email + '.' + exp, process.env.MAGIC_SECRET!);
const link = 'https://app/verify?e=' + encodeURIComponent(email) + '&exp=' + exp + '&sig=' + sig;

// verify: check sig + exp, then seal a session
if (Number(exp) > nowSeconds && (await hmacVerify(email + '.' + exp, sig, process.env.MAGIC_SECRET!))) {
  const setCookie = await sessions.seal({ email });
}

OWASP Top-10 checklist

RiskJORVEL mitigation
Broken access controlRBAC + middleware gate + requireUser
Injection / XSSReact escaping + CSP + sanitize + serializeState escapes </script>
CSRFSigned double-submit (verifyCsrf)
Security misconfigsecurityHeaders + policyHeaders defaults; secret-scanning (gitleaks) in CI
Vulnerable depsDependabot + CodeQL scaffolded at init
Auth failuresHMAC-signed sessions, rotation, rate-limit
SSRF / supply chainFederation origin allowlist + SRI on remoteEntry

BFF per remote

Give each remote its own backend-for-frontend: a server route namespace (/api/<remote>/*) owned by that team, deployed with the remote. The host proxies; the remote's loaders/actions call its own BFF. Keeps data contracts per-team and avoids a shared API monolith.

ts
// each remote ships its BFF routes under its own prefix
createApiRouter(dashboardRoutes, { prefix: '/api/dashboard' });
// host mounts all remote routers; a remote only owns its namespace

Translation management

Keep catalogs as JSON per locale (locales/<lc>.json) and sync with a TMS (Crowdin / Lokalise / Tolgee) via their CLI in CI: push source keys on merge to main, pull translations on a schedule into the catalog files @jorvel/i18n loads.

bash
# CI: push new source strings, pull completed translations
crowdin push sources        # or: lokalise2 file upload / tolgee push
crowdin pull                 # writes locales/*.json consumed by createI18n

More

See comparison, migration, and the API reference for the full surface.