@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

OptionTypeDefaultDescription
serverBuildPathstring—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).
serverBuildunknown—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.
basenamestring'/'Mount point for the SSR catch-all.
assetsDirstring—Directory served as static assets. Omitted → no asset route is registered at all.
assetUrlPrefixstring'/assets/'URL prefix the asset route claims. Read only when assetsDir is set.
publicFilesbooleantrueAlso 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.
loadRequestHandlerfunctionlazy importInjectable 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

ExportKind
assembleHandlerfunction
assertSsrRuntimefunction
bridgeRequestToRRfunction
contextKeyForfunction
createLoadContextFactoryfunction
createPublicFileHandlerfunction
createStaticAssetHandlerfunction
loadRequestHandlerfunction
ReactRouterPluginfunction
viteDevExternalsfunction
SsrServiceclass
CAPABILITIESconst
servicesContextconst
userContextconst
HandlerResultinterface
IFileSysteminterface
IRequestContextinterface
ISsrServiceinterface
RouterContextKeyinterface
RouterLoadContextinterface
SsrRuntimeinterface
ViteDevExternalIdinterface
ViteDevExternalsOptionsinterface
ViteDevExternalsPlugininterface
ViteHookContextinterface
ViteResolvedEnvironmentsinterface
PopulateLoadContexttype
ReactRouterPluginOptionstype
RouteHandlertype
SsrRequestHandlertype

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

OptionTypeDefaultDescription
serverBuildPathstringone of twoPath 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).
serverBuildunknownomittedThe 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>omittedInjectable 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.
assetsDirstringomittedFilesystem root of the built client bundle. Omit to disable the static-asset route.
assetUrlPrefixstring/assets/URL prefix for the asset route.
publicFilesbooleantrueAlso 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
basenamestring/Mount prefix for the SSR catch-all. MUST match react-router.config.ts basename for flat routes.
populateLoadContext(ctx: IRequestContext, context: RouterLoadContext) => voidomittedAdds 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 an IPlugin with async register() that mounts the SSR catch-all, optional static-asset route, and a react-router health indicator.
  • createStaticAssetHandler({ fs, assetsDir, assetUrlPrefix }) — returns a RouteHandler for serving built client assets with immutable caching. Traversal containment: request paths containing .. are rejected and every path is resolved under assetsDir; additionally, when the runtime provides IFileSystem.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 inside assetsDir pointing outside it is not served (404). When realPath is absent (edge runtimes), containment degrades to the lexical .. guard.
  • assembleHandler(build, createRequestHandler, mode): SsrRequestHandler — assembles an RR request handler from a pre-loaded ServerBuild and the createRequestHandler factory. Pure function; unit-testable without I/O.
  • loadRequestHandler(serverBuildPath, mode, options?): Promise<SsrRuntime> — lazily imports the app-provided server build and react-router, unwraps the ServerBuild (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’s instanceof check tests. The optional options parameter accepts { rrImportHook?: () => Promise<Record<string, unknown>> } — a test-seam that replaces the react-router import. Throws when the resolved module exposes no RouterContextProvider export (i.e. react-router earlier than 8).
  • createLoadContextFactory(rr): () => RouterLoadContext — pure seam building the per-request context factory from a loaded react-router module namespace. @throws when the module has no RouterContextProvider export.
  • assertSsrRuntime(value): SsrRuntime — validates an injected loadRequestHandler result during register(). @throws a message naming what was received when handler or createLoadContext is missing — notably for a bare handler function, the pre-populateLoadContext shape. 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 kernel IRequestContext into a web Request (omitting the body for GET/HEAD), builds a fresh context provider, invokes the RR handler, and maps the resulting Response back onto ctx.response. Its web Request uses publicRequestUrl(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, its createLoadContext factory, and the optional populateLoadContext hook; its render(ctx) method delegates to bridgeRequestToRR and returns the HandlerResult.
  • 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, or null on 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 to vite.createServer({ plugins }). It resolves each listed package, and any subpath of one, with resolve (pass import.meta.resolve from 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’s resolve.builtins in configResolved, appending because a configured list replaces Vite’s default one, which holds the Node built-ins. The patterns are jsr: and npm: specifiers, plus the directory of each package mapped to a file: 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-stack emits a dev.ts that uses it on Deno, Node and Bun; on Node and Bun Vite externalises node_modules packages anyway, so it changes nothing there.
  • interface RouterContextKey<T> — { readonly defaultValue?: T }. Structurally identical to React Router’s RouterContext<T>, so keys from this package and keys from createContext<T>() are interchangeable.
  • interface RouterLoadContext — get<T>(key) / set<T>(key, value); the per-request context React 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 context is a real RouterContextProvider. React Router 8 checks initialContext instanceof RouterContextProvider inside createRequestHandler and answers 500 Unexpected Server Error for 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 same react-router module the handler came from. Breaking change (unreleased): the getLoadContext option and the LoadContextFunction type are removed, because a function returning Record<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 loadRequestHandler that returns a handler over a build thunk — no plugin change required. See docs/react-router-dev.md for the verified recipe, including the base-prefix proxy route and the version pins React Router requires.

  • @react-router/node is excluded. Only core react-router (createRequestHandler) is lazy imported. @react-router/node’s installGlobals() is unnecessary on web-standard runtimes.

  • Static-asset serving uses runtime.fs?.readFile. On edge platforms where fs is absent, assets degrade to a 404. SSR document rendering still works.

  • File-based routing (flatRoutes) is supported transparently. It is baked into the compiled ServerBuild by the React Router Vite plugin at build time — M44 serves it without any plugin surface.

  • @react-router/fs-routes (if used) is an app-level devDependency, never imported by the plugin.


Agent card · TypeDoc API reference