maravilla-frameworks-react-router
Maravilla on React Router 7
@maravilla-labs/adapter-react-router is the RR7 adapter. Your build output ships ready for the Maravilla runtime; events / workflows under events/ and workflows/ are auto-discovered.
pnpm add -D @maravilla-labs/adapter-react-router
pnpm add @maravilla-labs/platform
Wire it in your react-router.config.ts:
import type { Config } from '@react-router/dev/config';
import { adapter } from '@maravilla-labs/adapter-react-router';
export default {
ssr: true,
adapter: adapter(),
} satisfies Config;
Auth in RR7 — server loader pattern
RR7 doesn't have a SvelteKit-style hook that runs before all loaders. The canonical pattern is a getSession(request) helper that runs the 3-step auth contract and is called from any loader that needs the caller. This is the verbatim shape from the production university app:
// app/lib/auth.server.ts
import { redirect } from 'react-router';
import { auth as platformAuth, DB } from './platform.server';
import type { User, UserRole } from './types';
type AuthUserLike = {
id: string;
email: string;
email_verified?: boolean;
groups?: string[];
profile?: Record<string, unknown>;
};
export interface SessionUser {
id: string;
email: string;
email_verified: boolean;
roles: string[];
groups: string[];
is_admin: boolean;
}
/**
* Resolve the caller's session (if any) from the platform.
*
* Three steps, all required:
* 1. `validate(token)` confirms the JWT and returns the `AuthUser`.
* 2. `setCurrentUser(token)` binds that identity for the rest of the
* request so subsequent DB/KV/Storage ops run as the user — without
* this bind, owner-scoped reads (including `users._id == auth.user_id`)
* resolve as anonymous and come back empty.
* 3. `ensureUserDoc(u)` guarantees the app-side `users` doc exists. The
* `onAuth({op:'registered'})` handler is the primary creator; this
* lazy upsert rescues OAuth sign-ups, pre-handler accounts, or any
* case where the platform event didn't land. After this call, every
* protected loader can rely on the invariant *valid session ⇒ users
* doc present*.
*/
export async function getSession(request: Request): Promise<SessionUser | null> {
const token = extractAccessToken(request);
if (!token) return null;
let u: AuthUserLike | null;
try {
u = (await platformAuth.validate(token)) as AuthUserLike | null;
} catch {
return null;
}
if (!u?.id) return null;
await platformAuth.setCurrentUser(token);
const appUser = await ensureUserDoc(u);
return {
id: u.id,
email: u.email,
email_verified: u.email_verified ?? false,
roles: [appUser.role],
groups: u.groups ?? [],
is_admin: appUser.role === 'admin',
};
}
export async function requireUser(request: Request): Promise<SessionUser> {
const session = await getSession(request);
if (!session) {
const url = new URL(request.url);
const next = encodeURIComponent(url.pathname + url.search);
throw redirect(`/_auth/login?next=${next}`);
}
return session;
}
/**
* Extract the access token from the request.
*
* The Maravilla hosted pages (`/_auth/login`, `/_auth/register`) set the
* `__session` cookie — that is the single source of truth. API clients may
* also pass the same JWT via an `Authorization: Bearer` header.
*/
function extractAccessToken(request: Request): string | null {
const authz = request.headers.get('authorization');
if (authz?.startsWith('Bearer ')) return authz.slice(7);
const cookie = request.headers.get('cookie');
if (!cookie) return null;
const sessionMatch = cookie.match(/(?:^|;\s*)__session=([^;]+)/);
return sessionMatch ? decodeURIComponent(sessionMatch[1]) : null;
}
Two crucial details to copy:
setCurrentUser(token)MUST run before any DB/KV/Storage op. Skipping it makes every owner-scoped read return zero rows because the policy engine sees an anonymous caller. There is no error — just empty data.validatefailures are anonymous;setCurrentUser/ensureUserDocfailures are 500s. A bad token is benign (treat as logged-out). A failure binding identity or provisioning the user doc is a contract violation — surface it, don't hide it as anonymous, otherwise the loader redirects to/_auth/loginand loops forever.
Lazy ensureUserDoc — handles missed onAuth events
The onAuth({op:'registered'}) event handler is the canonical place to mint the app-side users doc. But OAuth sign-ups, pre-handler accounts, and rare event-delivery misses can leave a valid session without a corresponding users row. The lazy fallback in getSession rescues those cases:
async function ensureUserDoc(u: AuthUserLike): Promise<User> {
const existing = (await DB.findOne('users', { _id: u.id })) as User | null;
if (existing) return existing;
const now = Date.now();
const profile = u.profile ?? {};
const displayName =
(typeof profile.display_name === 'string' ? profile.display_name : undefined)
?? (u.email ? u.email.split('@')[0] : 'New user');
const doc: User = {
_id: u.id,
display_name: displayName,
email: u.email ?? '',
role: 'student',
created_at: now,
/* ... */
};
try {
await DB.insertOne('users', doc);
return doc;
} catch (err) {
// Concurrent insert race — re-read; if a sibling won, return its doc
const raced = (await DB.findOne('users', { _id: u.id })) as User | null;
if (raced) return raced;
throw err;
}
}
Keep ensureUserDoc and the registration event handler in lockstep when either changes — duplication is intentional (the handler runs in a different runtime so a shared helper costs more than it saves).
Using session in loaders
// app/routes/dashboard.tsx
import { getSession, requireUser } from '~/lib/auth.server';
import { DB } from '~/lib/platform.server';
import type { Route } from './+types/dashboard';
export async function loader({ request }: Route.LoaderArgs) {
const session = await requireUser(request); // throws redirect to /_auth/login if no session
const myItems = await DB.find('items', { owner_id: session.id }, { limit: 50 });
return { session, myItems };
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>Welcome, {loaderData.session.email}</h1>
<ul>{loaderData.myItems.map((i) => <li key={i._id}>{i.title}</li>)}</ul>
</div>
);
}
Actions for mutations
// app/routes/items.tsx
import { redirect } from 'react-router';
import { requireUser } from '~/lib/auth.server';
import { DB } from '~/lib/platform.server';
export async function action({ request }: Route.ActionArgs) {
const session = await requireUser(request);
const fd = await request.formData();
const op = fd.get('_op');
if (op === 'create') {
await DB.insertOne('items', {
title: String(fd.get('title')),
owner_id: session.id,
created_at: Date.now(),
});
return { ok: true };
}
if (op === 'delete') {
const id = String(fd.get('id'));
await DB.deleteOne('items', { _id: id });
return { ok: true };
}
throw new Response('Unknown op', { status: 400 });
}
The Layer-2 policy on items (e.g. auth.user_id == node.owner_id || auth.is_admin) prevents one user from deleting another's items even if they forge the form.
Role gates
export async function requireStaff(request: Request): Promise<SessionUser> {
const session = await requireUser(request);
if (!(session.roles.includes('teacher') || session.is_admin)) {
throw new Response('Forbidden', { status: 403 });
}
return session;
}
export async function requireAdmin(request: Request): Promise<SessionUser> {
const session = await requireUser(request);
if (!(session.roles.includes('admin') || session.is_admin)) {
throw new Response('Forbidden', { status: 403 });
}
return session;
}
Logout
// app/routes/logout.tsx
import { redirect } from 'react-router';
export async function action() {
// Clear cookie via Set-Cookie header
return redirect('/', {
headers: {
'Set-Cookie': '__session=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax',
},
});
}
loadContext — passing platform to RR7 framework code
The adapter wires platform into RR7's loadContext so framework-level code (root error boundaries, middleware-style wrappers) can reach it without a direct import:
// inside a loader
export async function loader({ request, context }: Route.LoaderArgs) {
const { platform } = context;
const settings = await platform.env.KV.config.get('app');
return { settings };
}
Most code prefers the direct import { getPlatform } from '@maravilla-labs/platform' form — loadContext is for cases where you'd otherwise need to inject the import.
Common pitfalls
- Forgetting
setCurrentUseringetSession. The single most common bug — see maravilla-auth. Symptom: empty arrays on owner-scoped queries. - Returning
nullfromgetSessiononsetCurrentUserfailure. That swallows a contract violation; the loader then redirects to login forever. Throw or 500 instead — onlyvalidatefailures should produce a clean anonymous result. - Skipping
ensureUserDoc. OAuth users won't have an app-sideusersrow until they hit a code path that creates one — makegetSessionthe single guarantor. - Hard-coded HARDCODED_ADMIN_EMAILS. Project-owner-style admin promotion is fine to bake in; just keep the same list in
ensureUserDocand any role-recompute path.
Related skills
- maravilla-auth — the 3-step contract reference
- maravilla-events —
onAuth({op:'registered'})provisioning - maravilla-policies — the
auth.user_id == node.owner_idgate - maravilla-frameworks-sveltekit — the equivalent SvelteKit pattern
Full reference: https://www.maravilla.cloud/llms-full.txt.
More from maravilla-labs/maravilla-cli
maravilla-auth
Maravilla Cloud authentication. Use whenever wiring login/register/session, OAuth callbacks, resource policies, or hitting `platform.auth.*` APIs. Critical: the 3-step request-scoped contract (validate → setCurrentUser → can) — skipping any step silently breaks Layer-2 policies and owner-scoped reads return empty with no error.
12maravilla-events
Maravilla Cloud event handlers — files in `events/*.ts` auto-discovered by the framework adapter. Use to react to data changes (`onKvChange`, `onDb`), auth lifecycle (`onAuth`), schedule (`onSchedule`), queue messages (`onQueue`), realtime publishes (`onChannel`), deploy phases (`onDeploy`), object storage (`onStorage`), or arbitrary REN events (`defineEvent`). Run inside the Maravilla runtime with full platform access via `ctx`.
12maravilla-workflows
Maravilla Cloud durable workflows — replay-based, multi-step processes that survive restarts. Use whenever you need sleeps spanning minutes/hours/days, multi-step pipelines where each step's output feeds the next, waiting for external events, or strict step-history audit. `defineWorkflow` from `@maravilla-labs/functions/workflows/runtime` with `step.run`, `step.sleep`, `step.sleepUntil`, `step.waitForEvent`, `step.invoke`.
12maravilla-media-transforms
Async media + document derivations via `platform.media.transforms` and the declarative `transforms` block in `maravilla.config.ts`. Media: transcode video, thumbnail extraction, image resize/variants, OCR. Documents (.docx/.odt/.pptx/.xlsx/...): convert to PDF, render page thumbnails, generic format conversion, Markdown extraction (RAG-ready), single-file HTML with inlined images, image-replacement templating ({{TAG}} swap + named-object swap), QR-code injection. Use when ingesting user uploads that need normalised renditions, generating contracts/invoices from templates, or extracting structured content for LLMs. Critical: derived keys are content-addressed — `keyFor(srcKey, spec)` is known up front, before the worker starts, so clients can render placeholder UI without round-trips. Declarative config is the default; imperative `transforms.*` calls are for one-offs.
12maravilla-db
Maravilla Cloud document database — MongoDB-style queries, secondary indexes, and vector search. Use for structured app data, multi-field queries, sorting, semantic search via `findSimilar` / hybrid `find` with `options.vector`. Exposed as `platform.env.DB`. Vector indexes support int8/bit quantization, matryoshka, and multi-vector (ColBERT) out of the box.
11maravilla-config
The `maravilla.config.ts` declarative project file. Use whenever creating or modifying auth resources, groups, relations, registration fields, OAuth providers, password/session policy, branding, database indexes, or media transforms. Reconciled into delivery on every deploy — partial adoption is supported (omit a section to leave it untouched).
11