@setu-ts/react-router-plugin
Behind a reverse proxy, the request bridge uses publicRequestUrl(ctx) from @setu-ts/common so
loaders and actions see the public protocol and host when IP security has resolved them. Register
HttpSecurityPlugin({ ipSecurity: { trustProxy: true, trustedProxies: [...] } }) with the addresses
of your proxies; choose forwardedHeader: 'forwarded' if they write RFC 7239 instead of
X-Forwarded-*. With no IP security middleware, the bridge uses the original request URL, so behind
a TLS-terminating proxy every action answers 400: React Router’s own origin check compares the
browser’s https origin with the internal http URL (V10-41). A setu new --template full-stack
project reads the TRUSTED_PROXIES setting (a comma-separated CIDR list; empty leaves IP security
off) and configures this for you (M119c). Use a configured base URL for security-sensitive links
such as password resets. See
deployment behind a reverse proxy.
React SSR and file-based routing, by embedding React Router 8 framework mode as a plugin over a
kernel catch-all handler. Registers an SsrService under CAPABILITIES.SSR ('ssr').
Your React routes and your API routes live in one application, on one port, sharing the same plugin services.
Installation
import { ReactRouterPlugin } from '@setu-ts/react-router-plugin';
react-router is an optional dependency, lazily imported alongside your compiled ServerBuild.
Usage
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ReactRouterPlugin } from '@setu-ts/react-router-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
ReactRouterPlugin({
serverBuildPath: './build/server/index.js',
assetsDir: './build/client/assets',
}),
],
});
await app.start({ port: 3000 });
Options
| Option | Type | Default | Description |
|---|---|---|---|
serverBuildPath | string | — | Path to the compiled ServerBuild. Exactly one of serverBuildPath/serverBuild is supplied (a union type since 0.10.0). Correct for the socket targets; on Cloudflare Workers supply serverBuild — a bundled Worker cannot resolve new URL(…, import.meta.url). |
serverBuild | unknown | — | The namespace of the compiled ServerBuild, supplied as a module (0.10.0) — the form a bundled Cloudflare Worker uses; the generated full-stack entry imports ../build/server/index.js and passes it. |
basename | string | '/' | Mount point for the SSR catch-all. |
assetsDir | string | — | Directory served as static assets. Omitted → no asset route is registered at all. |
assetUrlPrefix | string | '/assets/' | URL prefix the asset route claims. Read only when assetsDir is set. |
publicFiles | boolean | true | Also serve files from the client build ROOT (Vite copies public/ there — robots.txt, favicon.ico) with must-revalidate, not immutable: those files are not content-hashed. false restores prefix-only behaviour. |
loadRequestHandler | function | lazy import | Injectable seam; defaults to importing react-router plus the build. |
On Cloudflare Workers leave assetsDir unset — there is no runtime.fs, so the asset handler has
nothing to read, and omitting it registers no route rather than throwing. The server build arrives
as a module: supply serverBuild (the generated full-stack Workers entry does this) rather than
serverBuildPath, whose new URL(…, import.meta.url) form cannot resolve in a bundled Worker.
Toolchain note
The frontend builds with Vite on the Node/npm toolchain. Vite is an app-level, build-time
devDependency; this plugin never imports it. Keep framework packages and workspace libraries
external, resolved by the server runtime, so route modules and the entry share one module instance:
context keys from contextKeyFor() match by identity. In development, viteDevExternals supplies
that guarantee; see
the development guide.
Loader context
The default load context carries two keys — servicesContext and userContext — so a loader can
reach any registered capability:
import { servicesContext, userContext } from '@setu-ts/react-router-plugin';
import type { RouterLoadContext } from '@setu-ts/react-router-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
export async function loader({ context }: { context: RouterLoadContext }) {
const services = context.get(servicesContext);
const user = context.get(userContext); // null when anonymous
const cache = services?.get<ICacheStore>(CAPABILITIES.CACHE);
return { user, featured: await cache?.get('featured') };
}
Override it with populateLoadContext to add your own keys.
Declaring your own context keys — always with contextKeyFor
A hand-written { defaultValue } literal type-checks and then silently returns its default:
keys are matched by identity, and in a framework-mode application the declaring module exists twice
(Vite inlines application modules into the server build while the kernel loads setu.config.ts from
source), so two identical-looking literals match nothing and context.get() answers the default
with no error anywhere. Construct every key through contextKeyFor, which resolves by
name so both copies share one object:
// app/lib/context-keys.server.ts
import { contextKeyFor } from '@setu-ts/react-router-plugin';
import type { ISession } from '@setu-ts/common';
export const sessionContext = contextKeyFor<ISession | null>('app.session', null);
Refusals and the error responder
React Router document requests and kernel API routes use different error protocols. A route
middleware refusal is rendered by the route’s React Router ErrorBoundary as HTML with the thrown
status. The kernel errorHandler({ format: 'rfc9457' }) continues to govern kernel routes and
responder terminals; the plugin does not rewrite a route boundary’s HTML into Problem Details.
For document navigation, throw redirect('/login') when the user is anonymous or
data(..., { status: 403 }) when the page is forbidden. A fetch caller that requires the API error
shape should call the corresponding kernel API route.
Routing
The catch-all is mounted on all seven verbs at joinWildcard(basename). flatRoutes and file-based
routing work transparently — they are resolved by the compiled build, not by this plugin.
Responses stream through IResponse.stream(); GET and HEAD request bodies are omitted.
Static assets
Assets are served over runtime.fs?.readFile with symlink-safe containment via the optional
IFileSystem.realPath. Where a runtime does not implement realPath, containment degrades to
lexical .. checking.
Health
Registers a react-router health indicator. There is no onClose — the request handler is
stateless.
Exports
| Export | Kind |
|---|---|
assembleHandler | function |
assertSsrRuntime | function |
bridgeRequestToRR | function |
contextKeyFor | function |
createLoadContextFactory | function |
createPublicFileHandler | function |
createStaticAssetHandler | function |
loadRequestHandler | function |
ReactRouterPlugin | function |
viteDevExternals | function |
SsrService | class |
CAPABILITIES | const |
servicesContext | const |
userContext | const |
HandlerResult | interface |
IFileSystem | interface |
IRequestContext | interface |
ISsrService | interface |
RouterContextKey | interface |
RouterLoadContext | interface |
SsrRuntime | interface |
ViteDevExternalId | interface |
ViteDevExternalsOptions | interface |
ViteDevExternalsPlugin | interface |
ViteHookContext | interface |
ViteResolvedEnvironments | interface |
PopulateLoadContext | type |
ReactRouterPluginOptions | type |
RouteHandler | type |
SsrRequestHandler | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
Trademarks
React Router is a trademark of Shopify Inc. Setu-TS is independent and is not affiliated with or endorsed by Shopify or the React Router project.
API reference
ReactRouterPlugin() (@setu-ts/react-router-plugin)
Embeds React Router 8 framework mode as a first-party plugin so a Setu-TS application can serve
a React frontend with Server-Side Rendering (SSR) and file-based routing. React Router’s
framework-mode createRequestHandler is mounted behind a kernel catch-all route; static client
assets are served over runtime.fs?.readFile.
A route-middleware refusal on a document request is rendered by React Router’s ErrorBoundary as
HTML with the thrown status. The kernel error responder governs kernel routes and is deliberately
not applied to that document protocol; API callers needing Problem Details call a kernel API route.
Registration
import { ReactRouterPlugin } from '@setu-ts/react-router-plugin';
app.register(
ReactRouterPlugin({
serverBuildPath: './build/server/index.js',
assetsDir: './build/client/assets',
assetUrlPrefix: '/assets/',
basename: '/',
mode: 'production',
}),
);
Usage in Routes
The plugin registers its own catch-all route internally — the consumer does not manually
register SSR routes. The plugin owns all HTTP verbs at the configured basename pattern:
import { CAPABILITIES, ISsrService } from '@setu-ts/common';
// The plugin handles SSR automatically at the catch-all.
// Any route that NAMES its path beats the catch-all, in either registration order:
// the kernel ranks candidates by static segments (descending), then wildcards
// (ascending), then registration index. So /login, /openapi.json and /api/users/:id
// all win over /*, and registration order does not enter into it.
// Known limit: the ranking compares COUNTS, so /a/* loses to /:x/b on /a/b.
// Before M70g a `*` counted as a static segment, so single-segment routes TIED with
// /* and whichever registered first won — which silently removed /openapi.json and
// /docs from every full-stack application.
app.router.get('/api/health', (ctx) => {
return ctx.response.json({ status: 'ok' });
});
Options
| Option | Type | Default | Description |
|---|---|---|---|
serverBuildPath | string | one of two | Path to the React Router Vite server build (default export = ServerBuild). Correct for the socket targets; on Cloudflare Workers the build arrives as a module — supply serverBuild. Exactly one of the two is supplied (a union type since 0.10.0 — breaking for implementors who extends ReactRouterPluginOptions). |
serverBuild | unknown | omitted | The namespace of the app’s compiled server build, supplied as a module (M113d §3.10) — the form a bundled Cloudflare Worker uses, where no path can be resolved at runtime. Mutually exclusive with serverBuildPath at the type level. |
loadRequestHandler | (buildPath, mode, options?) => Promise<SsrRuntime> | omitted | Injectable seam for lazy loading. When omitted, the default loads the build (by path or module). Passed serverBuildPath, or the '<serverBuild>' placeholder on the module arm, where options.serverBuild carries the module. |
assetsDir | string | omitted | Filesystem root of the built client bundle. Omit to disable the static-asset route. |
assetUrlPrefix | string | /assets/ | URL prefix for the asset route. |
publicFiles | boolean | true | Also serve files from the client build ROOT (Vite copies public/ there — robots.txt, favicon.ico) with must-revalidate, not immutable: those files are not content-hashed. false reproduces the previous prefix-only behaviour |
basename | string | / | Mount prefix for the SSR catch-all. MUST match react-router.config.ts basename for flat routes. |
populateLoadContext | (ctx: IRequestContext, context: RouterLoadContext) => void | omitted | Adds app values to the per-request React Router context, on top of the keys the plugin always sets. |
mode | 'production' | 'development' | 'production' | Passed to createRequestHandler(build, mode). |
Interface Reference
ISsrService.render(ctx): Promise<HandlerResult>— delegates the request to React Router and writes back the result (streaming or buffered).ReactRouterPlugin(options)— returns anIPluginwith asyncregister()that mounts the SSR catch-all, optional static-asset route, and areact-routerhealth indicator.createStaticAssetHandler({ fs, assetsDir, assetUrlPrefix })— returns aRouteHandlerfor serving built client assets with immutable caching. Traversal containment: request paths containing..are rejected and every path is resolved underassetsDir; additionally, when the runtime providesIFileSystem.realPath(the Node/Deno/Bun adapters do), both the assets root and the target are canonicalized and the target must stay inside the root — so a symlink insideassetsDirpointing outside it is not served (404). WhenrealPathis absent (edge runtimes), containment degrades to the lexical..guard.assembleHandler(build, createRequestHandler, mode): SsrRequestHandler— assembles an RR request handler from a pre-loadedServerBuildand thecreateRequestHandlerfactory. Pure function; unit-testable without I/O.loadRequestHandler(serverBuildPath, mode, options?): Promise<SsrRuntime>— lazily imports the app-provided server build andreact-router, unwraps theServerBuild(default export), and returns{ handler, createLoadContext }. Both come from the SAME module object so the provider instance is always an instance of the class the handler’sinstanceofcheck tests. The optionaloptionsparameter accepts{ rrImportHook?: () => Promise<Record<string, unknown>> }— a test-seam that replaces thereact-routerimport. Throws when the resolved module exposes noRouterContextProviderexport (i.e. react-router earlier than 8).createLoadContextFactory(rr): () => RouterLoadContext— pure seam building the per-request context factory from a loadedreact-routermodule namespace. @throws when the module has noRouterContextProviderexport.assertSsrRuntime(value): SsrRuntime— validates an injectedloadRequestHandlerresult duringregister(). @throws a message naming what was received whenhandlerorcreateLoadContextis missing — notably for a bare handler function, the pre-populateLoadContextshape. Without this, a wrong-shaped seam registers cleanly and then fails every request with an opaque 500.bridgeRequestToRR(ctx, handler, createLoadContext, populateLoadContext?): Promise<HandlerResult>— bridges a kernelIRequestContextinto a webRequest(omitting the body for GET/HEAD), builds a fresh context provider, invokes the RR handler, and maps the resultingResponseback ontoctx.response. Its web Request usespublicRequestUrl(ctx): loaders and actions see the published public origin, or the original URL byte for byte without publication.class SsrService implements ISsrService— holds a resolved RR request handler, itscreateLoadContextfactory, and the optionalpopulateLoadContexthook; itsrender(ctx)method delegates tobridgeRequestToRRand returns theHandlerResult.servicesContext: RouterContextKey<IServiceRegistry | null>— context key holding the kernel service registry. Always set by the plugin.userContext: RouterContextKey<IPrincipal | null>— context key holding the authenticated principal, ornullon an anonymous request.contextKeyFor<T>(name, defaultValue): RouterContextKey<T>— returns the key for a name, memoised, so the same name always yields the same object. Use it for any key an application declares for itself; see the note below on why a{ defaultValue }literal silently fails there.viteDevExternals({ packages, resolve }): ViteDevExternalsPlugin— a Vite plugin for development mode under Deno, passed tovite.createServer({ plugins }). It resolves each listed package, and any subpath of one, withresolve(passimport.meta.resolvefrom the development entry) and marks the result external, in the SSR environment only: a browser cannot import that specifier, so a client import is left to Vite. It appends the resolved specifiers to the SSR environment’sresolve.builtinsinconfigResolved, appending because a configured list replaces Vite’s default one, which holds the Node built-ins. The patterns arejsr:andnpm:specifiers, plus the directory of each package mapped to afile:URL. Vite’s runner then imports the module natively instead of loading a second copy. It is typed structurally (ViteDevExternalsOptions,ViteDevExternalsPlugin,ViteDevExternalId,ViteHookContext,ViteResolvedEnvironments), so this package imports no Vite.setu new --template full-stackemits adev.tsthat uses it on Deno, Node and Bun; on Node and Bun Vite externalisesnode_modulespackages anyway, so it changes nothing there.interface RouterContextKey<T>—{ readonly defaultValue?: T }. Structurally identical to React Router’sRouterContext<T>, so keys from this package and keys fromcreateContext<T>()are interchangeable.interface RouterLoadContext—get<T>(key)/set<T>(key, value); the per-requestcontextReact Router passes to loaders, actions, and middleware.interface SsrRuntime—{ handler: SsrRequestHandler; createLoadContext: () => RouterLoadContext }.type PopulateLoadContext—(ctx: IRequestContext, context: RouterLoadContext) => void.
Reading the load context in a route module
import { servicesContext, userContext } from '@setu-ts/react-router-plugin';
import { CAPABILITIES, type ILogger } from '@setu-ts/common';
export async function loader({ context }: Route.LoaderArgs) {
const services = context.get(servicesContext);
const user = context.get(userContext); // null when anonymous
services?.get<ILogger>(CAPABILITIES.LOGGER).info('ssr loader');
return { user };
}
Declaring your own context keys
An application that puts its own values on the request context — a session, a CSRF token, a service
it resolved once — must create those keys with contextKeyFor, not with a { defaultValue }
literal:
// app/lib/context-keys.server.ts
import { contextKeyFor } from '@setu-ts/react-router-plugin';
import type { ISession } from '@setu-ts/common';
export const sessionContext = contextKeyFor<ISession | null>('app.session', null);
Keys are matched by identity, and in a framework-mode application the declaring module reliably
exists twice: Vite inlines application modules into the server build, while the runtime loads
setu.config.ts from source. Two hand-written key objects then look identical and match nothing —
context.get() returns the default, so a session reads as null and a CSRF token as an empty
string, with no error anywhere. Resolving by name through this package gives both copies the same
object.
That guarantee needs this package to be a single module instance, which means the server build
must treat @setu-ts/* as external:
// vite.config.ts
export default defineConfig({
environments: {
ssr: {
build: {
rollupOptions: { external: ['@setu-ts/react-router-plugin'] },
},
},
},
});
Declared under environments.ssr.build, deliberately: React Router builds through Vite’s
Environment API, and neither a top-level ssr.external nor environments.ssr.resolve.external
reaches that build. setu new --template full-stack emits all of this already.
Development mode needs the same guarantee from Vite’s dev SSR runner, which loads route modules
itself. viteDevExternals keeps framework packages and workspace libraries external, resolved by
the server runtime, so route modules and the entry share one module instance. Context keys from
contextKeyFor() match by identity; a second copy would read different keys.
The same externalisation is what lets a server-only module (*.server.ts) import a framework
package by value at all. Client-reachable modules must stick to import type, which is erased: the
client bundle has to inline what it imports; server framework packages must stay on the server.
Notes
-
The React Router
contextis a realRouterContextProvider. React Router 8 checksinitialContext instanceof RouterContextProviderinsidecreateRequestHandlerand answers500 Unexpected Server Errorfor anything else — it does not degrade — and the static handler repeats the check nominally whenever route middleware runs. The plugin therefore constructs the provider from the samereact-routermodule the handler came from. Breaking change (unreleased): thegetLoadContextoption and theLoadContextFunctiontype are removed, because a function returningRecord<string, unknown>cannot produce a valid context under any wrapping. Migrate by mutating the provider instead of returning an object, and reading values through context keys:// Before — produced a 500 on every SSR request against react-router. ReactRouterPlugin({ serverBuildPath, getLoadContext: (ctx) => ({ db: myDb, user: ctx.request.user }), }); // After import { createContext } from 'react-router'; export const dbContext = createContext<Db | null>(null); ReactRouterPlugin({ serverBuildPath, populateLoadContext: (_ctx, context) => context.set(dbContext, myDb), }); // `servicesContext` and `userContext` are already set — populateLoadContext augments, never replaces. -
Vite is never imported by the plugin. In production the app feeds this plugin a compiled build; Vite stays an app-level, build-time concern. For a development loop with HMR and React Fast Refresh, the app runs Vite in-process and supplies a
loadRequestHandlerthat returns a handler over a build thunk — no plugin change required. See docs/react-router-dev.md for the verified recipe, including thebase-prefix proxy route and the version pins React Router requires. -
@react-router/nodeis excluded. Only corereact-router(createRequestHandler) is lazy imported.@react-router/node’sinstallGlobals()is unnecessary on web-standard runtimes. -
Static-asset serving uses
runtime.fs?.readFile. On edge platforms wherefsis absent, assets degrade to a 404. SSR document rendering still works. -
File-based routing (
flatRoutes) is supported transparently. It is baked into the compiledServerBuildby the React Router Vite plugin at build time — M44 serves it without any plugin surface. -
@react-router/fs-routes(if used) is an app-leveldevDependency, never imported by the plugin.