npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-router

2. 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 = true

The 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 a ws route) reaches alxia, with HMR.
  • bun run build writes build/server/index.js, a server you can run.
  • bun run start serves the pages and the client build. It listens on PORT (3000) and HOST (0.0.0.0), and shuts down gracefully on SIGTERM: the requests in flight finish, then the onStop hooks 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 reveal

It 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 Register declaration above, by that server: reading something no middleware derives is a compile error;
  • without it, AlxiaArgs<typeof server> or alxiaOf<typeof server>(context) names the server;
  • with neither, by the base @alxia/core's own Register names (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.

More

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 bun export condition, to resolve.conditions and resolve.externalConditions: a package that exports a bun variant is bundled, and loaded in dev, as that variant;
  • bun and bun:* to resolve.builtins: Bun's own modules stay imports of build/server/index.js;
  • build.target: 'esnext', where Vite's default targets browsers;
  • in the build only, resolve.noExternal: true: every package is bundled into build/server/index.js, so build/ runs with no node_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-app

Keep 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 with alxiaOf. More
  • Bun's user agent is a bot to isbot, so a test client gets the finished page, never a stream. Send a browser's user-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, with nonce: true and nonceOf in entry.server.tsx rather than 'unsafe-inline'. More
  • Under react-router dev, page() is not served, and an HTTP request's ctx.server is the loopback server the sockets are relayed to: its publish reaches them, its url and requestIP are not Vite's. More
  • An alxia route whose path covers a page takes it, wherever it is declared: GET /:slug answers /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 preview work, 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.ts with a session, an /api, secure headers with a nonce and a streamed page.
  • Recipes: Deploy with Docker, Health checks and graceful shutdown.