Coming from
Coming from React Router
React Router 7's framework mode mapped to Lumiose: file routes instead of routes.ts, async components instead of loaders, form actions and submit() instead of actions and fetchers, middleware, the document and custom servers.
This page maps React Router 7 in framework mode (the successor of Remix) to Lumiose's current API. For components and hooks, read Coming from React first; this page covers the framework.
#Mental model
Data lives in components, not in route modules. A React Router route module exports a
loader that runs before the route renders, and the component reads loaderData. Lumiose has no
loaders: the page component is async and awaits its data where it uses it, on
every rendering mode. Components that await the same fetchJSON() URL or query() key share one
request, so a layout and a page, or two widgets, can each await what they need
(Async components).
Actions are the familiar part. A page exports action (or named actions), which receives the
request's FormData and returns data, fail(status, data) or a redirect. A plain
<form method="post"> posts to it and works without JavaScript; onSubmit={submit} posts it
without a page load (Data and actions).
Navigation is a choice per route. React Router hydrates the whole app and navigates on the
client. In Lumiose, static and server routes are pages that ship no JavaScript except their
islands, and links load the next document (prefetched on hover, with optional View Transitions).
An spa route is the React Router experience: rendered on the server, hydrated, client-side
navigation with persistent layouts (Rendering modes). Most apps
mix them: static marketing pages, server account pages, an spa dashboard.
Routes are files only. There is no routes.ts and no route config in code: a
folder under src/routes is a URL segment, and page, layout, route, error and
not-found files give it behavior (Routing).
#From React Router to Lumiose
#Routes and types
| React Router | Lumiose |
|---|---|
app/routes.ts with route(), index(), layout(), prefix() |
Folders under src/routes; lumiose routes lists what the router sees. |
index("routes/home.tsx"), _index.tsx |
src/routes/page.stsx; about/page.stsx for /about. |
:id, :lang?, * (params["*"]) |
[id], [[lang]], [...rest] (one or more segments) or [[...rest]] (zero or more), read as params.rest. |
layout("routes/auth.tsx", [...]), pathless _auth.tsx |
A group folder with a layout: (auth)/layout.stsx. |
<Outlet /> |
props.children in a layout. |
Resource routes (a loader or action without a component) |
route.ts endpoints that export GET, POST, … as (ctx) => Response. |
import type { Route } from "./+types/page" |
PageProps<"/projects/[id]">, LayoutProps<"/projects">, ActionContext<"/projects/[id]"> and Endpoint<"/api/x">, generated by lumiose typegen. |
href("/projects/:id", { id }) |
href("/projects/[id]", { id }), type-checked against the routes. |
#Data
| React Router | Lumiose |
|---|---|
loader and useLoaderData() |
An async page component that awaits its data; server-only code lives in *.server.ts modules or src/server/. |
clientLoader |
On spa routes the page component also runs in the browser: await fetchJSON() of an endpoint, or query(key, fn). |
Promises returned from a loader, <Await> |
An async component inside <Suspense fallback>: on server routes it streams after the shell. |
useRouteLoaderData(), data from a parent route |
Await the same fetchJSON() URL or query() key again (one request per page), or provide a context from the layout. |
data(value, { status, headers }) from a loader |
response().setStatus() and response().setHeader() in the page, or a headers(ctx) export. |
throw data(…, { status: 404 }), throw redirect(…) |
notFound(), error(status) and redirect(to), which throw (Server). |
useRevalidator().revalidate() |
invalidate(); invalidate("/api/x", ["key"]) refetches only those entries. |
shouldRevalidate |
Choose what refetches: submit(form, { invalidate: ["/api/x"] }), or { invalidate: false }. |
#Mutations
| React Router | Lumiose |
|---|---|
action and useActionData() |
export const action, or export const actions = { name }, and actionData<typeof actions>(). |
<Form method="post"> |
<form method="post">, which works without JavaScript; onSubmit={submit} in an island or spa route posts it without a page load. |
An intent field to tell submissions apart |
Named actions: action="?/archive" on the form, or formaction="?/archive" on its button. |
clientAction |
An onSubmit handler in an island or spa route: run browser code, then await submit(event). |
useSubmit() |
submit(target, { action, method }): a form, a submit event, FormData, URLSearchParams or a record. |
useFetcher(): fetcher.submit(), <fetcher.Form>, fetcher.data |
submit(target, { action: "?/name" }), which never navigates unless the action redirects, and resolves with { type, status, data }. |
useFetcher() per instance: fetcher.state, fetcher.data |
submission<typeof actions.name>(): this component's submit, isSubmitting(), result(), error(), abort() and reset(). |
fetcher.load(href) |
An async component that awaits fetchJSON(href); invalidate(href) loads it again. |
useNavigation().state, fetcher.state |
isNavigating() (lumiose/router), isSubmitting(name?), and isBusy() for anything in flight. |
Optimistic UI from fetcher.formData |
optimistic(source) with .update(fn, submit(event)). |
createCookieSessionStorage(), createCookie() |
cookies and signedCookie(name) (Server). |
#Server, document and rendering
| React Router | Lumiose |
|---|---|
middleware export and context.set() / context.get() |
src/middleware.ts for every request, and a middleware.ts in a route folder for the routes below it: (ctx, next) => Response, run parent to child, with request data on ctx.locals (typed by augmenting Locals), read in pages with locals(). |
getLoadContext(), AppLoadContext |
ctx.platform (the host's bindings, waitUntil(), the client address) and env(name). |
root.tsx: <Meta />, <Links />, <Scripts />, <ScrollRestoration /> |
src/document.stsx renders {props.head} and {props.body}; stylesheets, scripts and scroll restoration are built in. |
meta and links exports |
<Title>, <Meta> and <Head> in any component (Head and document). |
headers export |
headers(ctx) export, response().setHeader(), middleware, or lumiose({ headers }) for every page. |
ErrorBoundary export, isRouteErrorResponse() |
error.stsx and not-found.stsx (the nearest wins, with { status, error }), and <ErrorBoundary> for an error in place. |
HydrateFallback |
<Suspense fallback>, or the shell of an ssr = false route. |
entry.server.tsx: handleRequest, streaming |
No file: the handler streams the shell, then <Suspense> boundaries; headers go in middleware. |
handleError in entry.server.tsx, server instrumentations |
src/instrumentation.server.ts: onRequestError(error, info) with the route and phase, and instrumentations that wrap request, middleware, action, endpoint, render and stream; call() never throws (Server). |
entry.client.tsx: hydrateRoot |
No file: the island loader and the SPA entry hydrate. Code that must run before hydration (an SDK, a polyfill) goes in src/instrumentation.client.ts, which every page runs first (Islands). |
Client instrumentations (router.navigate, router.fetch), client errors |
The instrumentations export of src/instrumentation.client.ts: navigate, submit, fetch and island hydrate wrappers with the same rules, and onError(error, info) for island, router and submit() errors (Islands). |
ssr: false (SPA mode) |
lumiose({ mode: "spa", ssr: false }), or export const ssr = false on an spa route. |
prerender: [...] |
static routes (the default) with staticPaths() for params, and export const prerender = true on spa pages. |
react-router-serve |
@lumiose/adapter-node's createServer(), or lumiose preview; other hosts in Deploy. |
Custom server: createRequestHandler() from @react-router/express |
createHandler() from @lumiose/adapter-node, a request listener or Express middleware; or dist/server/entry.js's fetch(request). |
Custom server in dev: Vite in middleware mode, ssrLoadModule() of the build |
createDevHandler({ server }) from @lumiose/vite/dev: the same kind of handler, with HMR and the overlay, so one server.ts serves dev and production (@lumiose/vite). |
#Navigation
| React Router | Lumiose |
|---|---|
<Link to>, <NavLink> |
<a href={href(…)}> or <Link href> from lumiose/router; <Link> sets aria-current="page" on the active link. |
<Link prefetch="intent"> |
<Link prefetch="hover">, the default; MPA pages prefetch through speculation rules. |
useNavigate() |
navigate(href, { replace }) from lumiose/router. |
useParams() |
props.params, or params() from lumiose/router (reactive, also on the server). |
useSearchParams() |
searchParams() and searchParams.set() from lumiose/router; url().searchParams in server code. |
useLocation() |
url() from lumiose/router; route() is the matched pattern. |
useBlocker(), unstable_usePrompt(), useBeforeUnload() |
beforeNavigate((navigation) => navigation.cancel()) from lumiose/router: link clicks, navigate() and back/forward stay on the page; for "leave" (closing the tab, a reload, a typed URL), the browser asks (hooks). |
A useEffect on useLocation() (page views, focus) |
afterNavigate(fn): after each completed navigation, and once on mount with type: "enter". |
#What has no equivalent
| React Router | Instead |
|---|---|
Routes defined in code (routes.ts, route()) |
File routes only. Colocate components and helpers next to the route; _private folders never route. |
clientMiddleware |
None. Run the code in the spa layout (effect, onMount) or in the handler that navigates. |
useFetchers() |
None: isSubmitting(name) counts every submission to an action, isBusy() anything in flight. |
handle and useMatches() |
Pass data down as props or context from the layout; compare route() with a pattern. |
useNavigation().location and .formData |
isNavigating() for pending UI, and optimistic() for the value being submitted. |
#Example
A project page with a loader, an action and a form, in React Router:
// app/routes/projects.$id.tsx
import { data, Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/projects.$id";
import { addTask, getProject } from "~/projects.server";
export async function loader({ params }: Route.LoaderArgs) {
const project = await getProject(params.id);
if (!project) throw data("Not found", { status: 404 });
return { project };
}
export async function action({ request, params }: Route.ActionArgs) {
const title = String((await request.formData()).get("title") ?? "").trim();
if (title === "") return data({ error: "A title is required." }, { status: 400 });
await addTask(params.id, title);
return redirect(`/projects/${params.id}`);
}
export function meta({ data }: Route.MetaArgs) {
return [{ title: data.project.name }];
}
export default function Project({ loaderData, actionData }: Route.ComponentProps) {
const navigation = useNavigation();
return (
<main>
<h1>{loaderData.project.name}</h1>
<ul>
{loaderData.project.tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
<Form method="post">
<input name="title" />
<p>{actionData?.error}</p>
<button disabled={navigation.state === "submitting"}>Add task</button>
</Form>
</main>
);
}The same page in Lumiose. The data access is unchanged:
// src/lib/projects.server.ts: a database in a real app
interface Task {
id: number;
title: string;
}
const projects = new Map([["1", { name: "Write the docs", tasks: [] as Task[] }]]);
export async function getProject(id: string) {
return projects.get(id);
}
export async function addTask(id: string, title: string) {
projects.get(id)?.tasks.push({ id: Date.now(), title });
}The page awaits the project in its component, and its action returns fail() for validation
errors. It is a server page: no JavaScript ships except the form's island.
// src/routes/projects/[id]/page.stsx
import { Title, fail, href, notFound, redirect, type ActionContext, type PageProps } from "lumiose";
import { AddTask } from "../../../components/AddTask.stsx";
import { addTask, getProject } from "../../../lib/projects.server.ts";
export const mode = "server";
export const actions = {
async add(ctx: ActionContext<"/projects/[id]">) {
const title = String((await ctx.formData()).get("title") ?? "").trim();
if (title === "") return fail(400, { error: "A title is required." });
await addTask(ctx.params.id, title);
redirect(href("/projects/[id]", { id: ctx.params.id }));
},
};
export default async function Project({ params }: PageProps<"/projects/[id]">) {
const project = (await getProject(params.id)) ?? notFound();
return (
<main>
<Title>{project.name}</Title>
<h1>{project.name}</h1>
<ul>
{project.tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
<AddTask client:load />
</main>
);
}The form is an island in its own module, so the server-only import above never reaches the
browser; it imports the action's type only. Without JavaScript the form posts and the page
re-renders with the error or follows the redirect; with it, submit() does the same without a
page load:
// src/components/AddTask.stsx
import { actionData, isSubmitting, submit } from "lumiose";
import type { actions } from "../routes/projects/[id]/page.stsx";
export function AddTask() {
return (
<form method="post" action="?/add" onSubmit={submit}>
<input name="title" />
<p class="error">{actionData<typeof actions>()?.error}</p>
<button disabled={isSubmitting("add")}>Add task</button>
</form>
);
}
<style>
.error { color: crimson; }
</style>Context from middleware, where React Router uses context.set() in a middleware export:
// src/middleware.ts
import { signedCookie, type Middleware } from "lumiose";
const session = signedCookie("session");
declare module "lumiose" {
interface Locals {
userId: string | null;
}
}
const middleware: Middleware = async (ctx, next) => {
ctx.locals.userId = (await session.get(ctx.cookies)) ?? null;
return next();
};
export default middleware;A route's middleware for its subtree is a middleware.ts in its folder. It runs after
src/middleware.ts, for every page, action and endpoint below the folder:
// src/routes/projects/middleware.ts
import type { Middleware } from "lumiose";
const requireUser: Middleware<"/projects"> = (ctx, next) =>
ctx.locals.userId === null ? Response.redirect(new URL("/login", ctx.url), 303) : next();
export default requireUser;A custom server, where React Router uses createRequestHandler():
// server.ts: node server.ts after lumiose build
import { createServer } from "node:http";
import { createHandler } from "@lumiose/adapter-node";
const app = await createHandler(); // the build in dist/: static files, then the app
createServer((req, res) => {
if (req.url === "/healthz") {
res.end("ok");
return;
}
app(req, res);
}).listen(Number(process.env.PORT ?? 3000));#Common mistakes
- Exporting a
loader. Nothing calls it. Await the data in the page component. - Expecting client-side navigation everywhere.
staticandserverroutes load the next document. Make app-like areasspa(in their layout), and keep the rest as pages. - Expecting an MPA page to revalidate after
submit(). React Router reloads loaders after an action; on astaticorserverpagesubmit()does not refresh the page. Redirect from the action (as above), update the island from the result, or callinvalidate(). Onsparoutes a successfulsubmit()refreshes the page's data. - Server code in an island's module. An island's module ships to the browser, so importing a
*.server.tsthere isLUM4003. Keep islands in their own files and import action types withimport type. - Throwing for validation errors. A thrown error renders the
errorpage; returnfail(400, data)as you would returndata(…, { status: 400 }). - Reading
params["*"]. A rest segment has a name:[...path]isparams.path. - Reading the request in
sparoutes. Their code also runs in the browser, socookies()andlocals()are not available there; call an endpoint (LUM4003). - Hard-coding
/projects/${id}.href("/projects/[id]", { id })fails type-checking when the route moves.
#Related
- Routing: file routes, layouts, params, typed
href()and the SPA router. - Data and actions: endpoints, form actions,
submit(),invalidate()andoptimistic(). - Async components: awaiting data, the request cache,
<Suspense>and streaming. - Server: middleware,
locals, cookies, redirects and env. - Rendering modes:
static,serverandsparoutes, prerendering and the build. lumiose/router:Link,navigate,params,searchParamsandisNavigating.@lumiose/adapter-node:createServerandcreateHandler.- Coming from React: hooks, components and JSX.