@alxia/react-router
v0.8.0
Published
alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served
Readme
@alxia/react-router
alxia as the HTTP server of a
React Router framework app, server rendered,
under Bun. One Vite plugin and no server file: the pages are served by
alxia in react-router dev and from the build. When you want more,
app/server.ts adds the app's middlewares — logger, compression, sessions,
guards — and an /api beside the pages, and each loader reads what those
middlewares built, typed.
Quick start
A new app
bun create @alxia my-app --template react-router
cd my-app
bun dev@alxia/create writes React
Router's official template with everything below already done: this
package and @alxia/core installed, alxia() in vite.config.ts, the
bunfig.toml, start on Bun, Biome, and a Dockerfile that builds on
oven/bun and runs build/ alone on Alpine. Its dependencies are
moved to the newest releases alxia accepts.
An existing React Router app
Start from the official template, bunx create-react-router@latest, or your
own app, and make four changes.
1. Install alxia and this package:
bun add @alxia/core @alxia/react-router2. Add the plugin to vite.config.ts. It can go anywhere in plugins:
+import { alxia } from "@alxia/react-router/vite";
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
- plugins: [tailwindcss(), reactRouter()],
+ plugins: [tailwindcss(), reactRouter(), alxia()],
resolve: {
tsconfigPaths: true,
},
});3. Run the scripts on Bun, with a bunfig.toml:
# bunfig.toml, beside package.json
[run]
bun = trueThe react-router CLI starts with #!/usr/bin/env node. Where a node is
installed, bun run dev would run it on Node, and alxia's server needs
Bun. bun = true makes bun run start it on Bun. Without it, the dev
server stops at startup with
alxia-react-router: react-router dev is running on Node, ….
4. Start with Bun, in package.json:
- "start": "react-router-serve ./build/server/index.js",
+ "start": "NODE_ENV=production bun build/server/index.js",With the bunfig.toml in place, the template's dev, build and
typecheck scripts are unchanged. alxia's dev helps follow React Router's
mode, on under react-router dev; bun create @alxia writes
NODE_ENV=development react-router dev as dev, so that the app's own
Bun.env reads agree:
bun run dev: every request Vite does not answer itself (pages, data,/api, an upgrade to awsroute) reaches alxia, with HMR.bun run buildwritesbuild/server/index.js, a server you can run.bun run startserves the pages and the client build. It listens onPORT(3000) andHOST(0.0.0.0), and shuts down gracefully onSIGTERM: the requests in flight finish, then theonStophooks run.
After a build, bunx --bun vite preview serves that built server through
Vite's preview server, as bun run start would: the pages, /api, the
client's files and the app's middlewares. So does React Router's prerendering.
@alxia/core and react-router 8 are peers, and vite 7 or 8 is an
optional peer, for /vite. The package declares no dependency.
Its typescript peer is 6 or 7. The template's TypeScript 5.9 typechecks,
but bun add warns about it; bun add -d typescript@^6 silences the
warning.
Customising: app/server.ts
Start from the default server, written out:
bunx alxia-react-router revealIt writes app/server.ts (or the file alxia({ entry }) names):
createServer() with beforeAll, configure and getLoadContext
commented, and the Register declaration. It refuses to overwrite a file
already there; --force overwrites it. Run it from the app's root, once
@alxia/react-router is installed.
The examples use @alxia/logger (bun add @alxia/logger); any middleware
works the same way (use the observers first). The Vite plugin picks the file up in dev and in the build:
// app/server.ts
import { logger } from '@alxia/logger';
import { createServer } from '@alxia/react-router';
const server = createServer({
configure: (app) =>
app
.use(logger())
.get('/api/health', ({ reply }) => reply.ok({ ok: true }))
.derive(({ request }) => {
const name = request.headers.get('x-user');
return { user: name === null ? null : { name } };
}),
});
export default server;
// What alxiaOf(context) reads in the loaders.
declare module '@alxia/react-router' {
interface Register {
server: typeof server;
}
}configure receives the alxia app and returns it, so what it builds is
typed. The plugin wires React Router's build, the mode and the client
folder.
A request runs through beforeAll, then the client's files, then
configure, then the pages. So a guard in configure does not block the
login page's JavaScript.
Reading the context in a loader
What alxia's middlewares built for a request reaches a loader, an action or
a middleware in three forms, each the same object, typed by the Register
declaration above:
| Form | Use it |
| --- | --- |
| withAlxia(fn) | with the generated Route.LoaderArgs and Route.ActionArgs: alxia beside the arguments |
| alxiaOf(context) | inside a plain function, or with a type argument where nothing is registered |
| context.alxia | with LoaderFunctionArgs and ActionFunctionArgs from react-router |
// app/routes/home.tsx
import { type AlxiaArgs, withAlxia } from '@alxia/react-router';
import type { Route } from './+types/home';
export const loader = withAlxia(({ alxia }: Route.LoaderArgs & AlxiaArgs) => {
alxia.log.info('home'); // typed: what configure built
return { name: alxia.user?.name ?? 'anonymous' };
});withAlxia returns a function of React Router's arguments alone, with
fn's return type: Route.ComponentProps['loaderData'] and
useLoaderData<typeof loader> read what fn returns. It works for an
action, and for a middleware, whose next it passes on.
alxiaOf(context), in a plain function:
import { alxiaOf } from '@alxia/react-router';
export async function action({ context }: Route.ActionArgs) {
const { user, log } = alxiaOf(context);
log.info('saved', { by: user?.name });
return { ok: true };
}How each is typed:
- with the
Registerdeclaration above, by that server: reading something no middleware derives is a compile error; - without it,
AlxiaArgs<typeof server>oralxiaOf<typeof server>(context)names the server; - with neither, by the base
@alxia/core's ownRegisternames (context: typeof base), when the app has one; - with none of them,
BaseContext.
This package's Register wins over core's: the server's app is the base
and all configure adds after it.
The shorthand: context.alxia
The same object, with no import and no call, where the arguments are react-router's own types:
import type { LoaderFunctionArgs } from 'react-router';
export function loader({ context }: LoaderFunctionArgs) {
const { user, server } = context.alxia;
return { name: user?.name ?? 'anonymous' };
}With the generated Route.LoaderArgs it is there at runtime, but tsc
reports Property 'alxia' does not exist on type
'Readonly<RouterContextProvider>' (TS2339): react-router's ./internal
types, which the generated route types read, point at its development
build, a second declaration of RouterContextProvider that no package can
augment. Use withAlxia there until react-router ships one declaration.
More
The server: server
alxiaOf(context).server is the Bun.Server serving the request, core's
ctx.server: its publish reaches the app's WebSocket
subscribers from an action.
export async function action({ context }: Route.ActionArgs) {
alxiaOf(context).server?.publish('todos', JSON.stringify({ changed: true }));
return { ok: true };
}It is the listening server behind bun build/server/index.js. Under
react-router dev and vite preview, Vite's server is node:http, so it is
the Bun.Server the app's sockets are relayed to, on a loopback port:
publish reaches them, but its url is that port's, not Vite's, and its
requestIP and timeout know nothing of a request Vite took. Through
app.request(…), as in a test, it is undefined.
It is not Register's server, which names what createServer() returned
for the types.
A CSP nonce
With secureHeaders({ nonce: true }) from @alxia/secure-headers in
configure, each request's policy names a fresh nonce, and the context
carries it. nonceOf(loadContext) reads it in entry.server.tsx, and React
Router puts it on every script it renders, so script-src needs no
'unsafe-inline':
// app/server.ts
configure: (app) =>
app.use(
secureHeaders({
nonce: true,
contentSecurityPolicy:
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'",
}),
),The template has no entry.server.tsx; reveal React Router's, then add
three lines:
bunx react-router reveal entry.server // app/entry.server.tsx, as reveal writes it
import { PassThrough } from "node:stream";
+import { nonceOf } from "@alxia/react-router";
import type { EntryContext, RouterContextProvider } from "react-router";
…
const { pipe, abort } = renderToPipeableStream(
- <ServerRouter context={routerContext} url={request.url} />,
+ <ServerRouter context={routerContext} url={request.url} nonce={nonceOf(loadContext)} />,
{
+ nonce: nonceOf(loadContext),
[readyOption]() {nonceOf returns undefined when no middleware set a nonce, so the entry works
with or without secure-headers: neither package depends on the other. Any
derive that returns a string nonce works the same.
More
WebSockets
A ws route in configure connects under react-router dev, under
vite preview and from the build alike, with nothing else to write:
// app/server.ts
import { createServer } from '@alxia/react-router';
export default createServer({
configure: (app) =>
app
.derive(({ request }) => ({ user: request.headers.get('x-user') }))
.ws('/api/echo', {}, {
open: (socket) => socket.send({ hello: socket.data.user ?? 'anonymous' }),
message: (socket, message) => socket.send({ echo: String(message) }),
}),
});In dev, the plugin hands each upgrade Vite does not claim (its HMR, its
server.proxy) to the app, run by Bun.serve as listen runs it: the
middlewares before the route, a refusal's status, socket.data, publish. An
edit to the server is used from the next connection. An action's
alxiaOf(context).server.publish reaches those sockets too
(The server).
More
Options
// app/server.ts
import { trustProxy } from '@alxia/core';
import { logger } from '@alxia/logger';
import { createServer } from '@alxia/react-router';
import { greetingContext } from './context'; // createContext<string>('unset'), in app/context.ts
export default createServer({
beforeAll: (app) => app.use(logger()), // runs before the client's files too
configure: (app) => app.get('/api/health', ({ reply }) => reply.ok({ ok: true })),
getLoadContext: (_ctx, context) => context.set(greetingContext, 'hello'),
proxy: trustProxy({ trusted: ['10.0.0.0/8'] }), // behind a TLS proxy: request.url is the public URL
listen: { idleTimeout: 30 }, // its port and hostname, if given, win over PORT and HOST
onListen: (server) => console.log(`up on ${server.url}`),
});proxy is alxia({ proxy })'s option: behind the proxies it names,
ctx.ip is the client's and React Router's request.url the URL the
client asked for, https:// and the public host, so a loader can build an
absolute link from it; from any other connection the forwarded headers are
ignored. A server of your own gives alxia({ proxy }) the same option. More
build, mode and client override what the plugin wires;
client: false serves no client files, so you can serve them yourself.
A server of your own, with no plugin, calls reactRouter(app, { build,
client }) itself. More
The client's files
In a build, build/client is served before configure's middlewares:
| path | Cache-Control |
| --- | --- |
| /assets/*, the hashed bundles | public, max-age=31536000, immutable |
| every other top-level file or folder, the copies of public/ | public, max-age=3600 |
A missing asset gets alxia's JSON 404, not a page. In dev, Vite serves these files.
Leaving the pages out of matchesSpec
An API under /api, spec first, is checked against its OpenAPI document
by @alxia/openapi's
matchesSpec (bun add -d @alxia/openapi), which reads app.routes. The
catch-all and the client's files are routes too, and no operation of the
document: left alone, they only come back as extra, and strict: true
makes them fail:
// app/server.test.ts
import { test } from 'bun:test';
import { matchesSpec } from '@alxia/openapi';
import { isReactRouterRoute } from '@alxia/react-router';
import type { ServerBuild } from 'react-router';
import { operations } from './generated/alxia';
import server from './server';
test('the API routes every operation of openapi.yaml, and nothing else', async () => {
const build: ServerBuild = await import(new URL('../build/server/index.js', import.meta.url).href);
const app = server.create({ build });
matchesSpec(app, operations, { strict: true, exclude: isReactRouterRoute });
});isReactRouterRoute is true for each route this package declared, so
matchesSpec checks your own routes alone, under strict.
Built for Bun
The server build runs on Bun, so the plugin builds it for Bun, with
nothing to configure. In Vite's ssr environment, in dev and in the
build, it adds:
- the
bunexport condition, toresolve.conditionsandresolve.externalConditions: a package that exports abunvariant is bundled, and loaded in dev, as that variant; bunandbun:*toresolve.builtins: Bun's own modules stay imports ofbuild/server/index.js;build.target: 'esnext', where Vite's default targets browsers;- in the build only,
resolve.noExternal: true: every package is bundled intobuild/server/index.js, sobuild/runs with nonode_modules.
// app/server.ts: Bun's modules, bundled as they are
import { createServer } from '@alxia/react-router';
import { Database } from 'bun:sqlite';
const db = new Database(':memory:');
export default createServer({
configure: (app) =>
app.get('/api/sqlite', ({ reply }) =>
reply.ok(db.query('select sqlite_version() as version').get()),
),
});What the app sets wins: its own conditions and builtins are kept, bun is
added beside them, and a build.target it set is left as it is.
ssr.target stays node, and no polyfill is added; an app that sets
ssr.target: 'webworker' itself gets none of this, only Vite's defaults.
A package that cannot be bundled, a native addon, stays external when the
app names it, and every package does with ssr.external: true:
// vite.config.ts: sharp imported from node_modules at runtime
export default defineConfig({
ssr: { external: ['sharp'] },
plugins: [reactRouter(), alxia()],
});The guide has each option and why.
Docker
A multi-stage Dockerfile on Bun's official images, built on Debian's
and run on Alpine's, as @alxia/create's
react-router template ships it. For an app from create-react-router,
it replaces the template's, which runs on Node. The build is
self-contained, so the image holds build/ alone, no node_modules:
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock* bunfig.toml* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./build
USER bun
EXPOSE 3000
CMD ["bun", "--no-install", "build/server/index.js"]docker build -t my-app .
docker run -p 3000:3000 my-appKeep the template's .dockerignore, and commit bun.lock. The server
runs as the non-root bun user, on PORT (3000). The
guide
has the commented file, and what to copy for a package left external.
Traps
- A context key made in
app/reaches the loaders only through the plugin, which builds the server with the routes. A server of your own, without the plugin, holds another copy of the key: read alxia's context withalxiaOf. More - Bun's user agent is a bot to
isbot, so a test client gets the finished page, never a stream. Send a browser'suser-agent. More - An index route's action is
POST /?index;POST /gets React Router's 405. More @alxia/secure-headers' default policy blocks the page's scripts and forms: give the pages a policy of their own, withnonce: trueandnonceOfinentry.server.tsxrather than'unsafe-inline'. More- Under
react-router dev,page()is not served, and an HTTP request'sctx.serveris the loopback server the sockets are relayed to: itspublishreaches them, itsurlandrequestIPare not Vite's. More - An alxia route whose path covers a page takes it, wherever it is
declared:
GET /:sluganswers/about. Keep alxia's routes under/api. More
API
| export | |
| --- | --- |
| createServer(options?) | the server of app/server.ts. beforeAll, configure, getLoadContext, proxy (alxia({ proxy })'s, trustProxy(…) from @alxia/core), build, mode, client, listen, onListen |
| ServerOptions<Before, App> | its options |
| ReactRouterServer<App> | what it returns: create(wiring) makes the app, start(app) listens |
| ServerWiring | what create takes: build, mode, client |
| FreshApp | the app beforeAll, or configure without it, receives |
| alxiaOf<App>(context) | what alxia's middlewares built, in a loader, an action or a middleware. Typed by the registered server, by the type argument (a server or an app), or as BaseContext. Its server is the Bun.Server serving the request |
| context.alxia | the same object, on React Router's RouterContextProvider (this package augments its type, as alxiaOf(context) with no type argument): typed with LoaderFunctionArgs and ActionFunctionArgs from react-router, not yet with the generated Route.LoaderArgs: use withAlxia there |
| withAlxia(fn) | a loader, an action or a middleware given alxia, the alxia context, beside React Router's arguments: annotate Route.LoaderArgs & AlxiaArgs. Returns a function of the arguments alone, with fn's return type |
| AlxiaArgs<App> | what withAlxia adds: { alxia }, typed by Register, or by the server it names |
| AlxiaContextOf<App> | what alxiaOf<App>(context) returns: the app's context and the catch-all's route |
| ProviderLike | what alxiaOf and nonceOf take: any React Router context provider's get, the generated route types' included |
| Register | the interface to augment with server: typeof server |
| RegisteredApp | the app alxiaOf reads with no type argument: the registered server's, else the one @alxia/core's Register names, else a fresh app |
| RegisteredOf<R, Core?> | the app a Register-shaped interface names: its server's, InvalidRegister, or with no server Core, by default core's RegisteredBase |
| InvalidRegister | what a Register naming neither a server nor an app reads as: every key of the app's own a compile error |
| AppOf<Server> | the app a server makes |
| alxiaContext | the React Router context key alxiaOf and context.alxia read, set on every request |
| nonceOf(context) | the request's CSP nonce, for entry.server.tsx: the context's nonce when a middleware set one, such as secureHeaders({ nonce: true }), else undefined |
| reactRouter(app, options) | the catch-all and the client's files, for a server of your own. build, mode, getLoadContext, client |
| ReactRouterOptions<Ctx> | its options |
| isReactRouterRoute(route) | whether this package declared a route, for matchesSpec's exclude |
From @alxia/react-router/vite:
| export | |
| --- | --- |
| alxia(options?) | the Vite plugin, for react-router dev, react-router build and vite preview, building the server for Bun. entry is the server file: app/server.ts by default, or the default server when there is none |
| AlxiaOptions | its options |
The alxia-react-router bin, run with bunx:
| command | |
| --- | --- |
| reveal [--force] | writes the default server to app/server.ts, or to alxia({ entry })'s file; refuses an existing file without --force |
Documentation
- Guide: the setup, how dev, the build and
vite previewwork, what the build sets for Bun, customising the server, typing the loaders, the app's own keys, a CSP nonce, escape hatches, WebSockets, the client's files, OpenAPI, testing and deploying, with Docker. - Troubleshooting: each message, and the traps that print none.
- Roadmap: what is coming, and what is not planned.
- Example: the official template, these four changes, then an
app/server.tswith a session, an/api, secure headers with a nonce and a streamed page. - Recipes: Deploy with Docker, Health checks and graceful shutdown.
