react-router-effect 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,225 +1,778 @@
1
1
  # react-router-effect
2
2
 
3
- Wrap [React Router](https://reactrouter.com) framework-mode loaders, actions, and middleware with
4
- [Effect](https://effect.website), and get **typed, declarative error handling** for free.
3
+ Serve a [React Router](https://reactrouter.com) framework-mode app from [Effect](https://effect.website)'s
4
+ HTTP router, on Node or on Cloudflare Workers. Write its loaders, actions and middleware as Effects.
5
5
 
6
- Write your loader/action/middleware as an `Effect`. When it short-circuits with a tagged error,
7
- the library decides — based on handlers you register once — whether to **recover** (return data
8
- the component reads via `useLoaderData`) or **throw** (send it to the error boundary or issue
9
- a redirect). The resolved type of every loader/action reflects exactly what it can return.
6
+ A route function that returns an `Effect` runs with your app's services. When it fails with a tagged
7
+ error, the route factory you configure once decides what happens: recover with data for
8
+ `useLoaderData`, send the error to the error boundary, or redirect. The type of the route's data
9
+ lists what the route can return.
10
10
 
11
11
  ```ts
12
- const loader = makeLoader((args: Route.LoaderArgs) =>
13
- Effect.gen(function* () {
14
- const user = yield* getUser(args); // may fail with your domain errors
15
- if (!user.onboarded) {
16
- yield* Respond.redirect("/onboarding"); // throw → redirect
17
- }
18
- return { user };
19
- }),
20
- );
21
- // loader: (args) => Promise<{ user: User } /* | recovered shapes */>
12
+ export const loader = Effect.fn("loader.profile")(function* ({
13
+ params,
14
+ }: EffectRouteArgs<Route.LoaderArgs>) {
15
+ const profile = yield* (yield* Profiles).get(params.id); // fails with your domain errors
16
+ if (!profile.public) yield* Respond.early({ restricted: true }); // recover with typed data
17
+ return { profile };
18
+ });
19
+ // Route.ComponentProps["loaderData"]: { profile: Profile } | { restricted: boolean }
22
20
  ```
23
21
 
22
+ If your app does not serve React Router from an Effect HTTP server, read
23
+ [Without the Effect HTTP server](#without-the-effect-http-server).
24
+
24
25
  ## Install
25
26
 
27
+ On Node:
28
+
26
29
  ```bash
27
- vp add react-router-effect effect react-router
30
+ vp add react-router-effect effect react-router @effect/platform-node
28
31
  ```
29
32
 
30
- `effect` and `react-router` are peer dependencies. The library requires Effect 4 (`effect@^4.0.0`).
33
+ `effect`, `react-router` and `@effect/platform-node` are peer dependencies. The library requires
34
+ Effect 4.1 (`effect@^4.1.0`) and Node 22 or later. Only `react-router-effect/vite` imports
35
+ `@effect/platform-node`, so another platform does not need it. The README of each example lists the
36
+ packages of its platform. Read [Platforms](#platforms).
31
37
 
32
- ## Concepts
38
+ ## Get started
33
39
 
34
- A loader/action effect can short-circuit in three ways, via the `Respond` helpers:
40
+ These steps serve an app on Node in production. [`examples/node`](examples/node) is an app with
41
+ these files. It also has development with Vite, tests against the production build, and benchmarks.
35
42
 
36
- | Helper | Outcome | Where it lands |
37
- | ------------------------------ | ----------- | --------------------------------------------------- |
38
- | `Respond.early(value, init?)` | **recover** | resolves with `data(value, init)` → `useLoaderData` |
39
- | `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
40
- | `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
43
+ ### 1. Create the route factory
41
44
 
42
- You declare your app's **domain errors** as a type argument to the factory, and may register a
43
- handler per domain error. A handler _remaps_ an error by returning either:
45
+ The route factory has what every route Effect needs: the runtime that provides your services, and
46
+ the handlers for your domain errors.
44
47
 
45
- - a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
46
- - an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
47
- to **throw** a `Response` / `DataWithResponseInit`.
48
+ ```ts
49
+ // app/lib/route-factory.server.ts
50
+ import { ManagedRuntime } from "effect";
51
+ import { makeEffectRouteFactory } from "react-router-effect";
52
+
53
+ import { InvalidTodo, Todos } from "./services.server";
48
54
 
49
- A **declared domain error** may be left unhandled:
55
+ export const runtime = ManagedRuntime.make(Todos.layer);
50
56
 
51
- - if it implements [`HttpServerRespondable`](https://effect.website) it's rendered automatically
52
- from its own response;
53
- - otherwise it falls through to a **500**.
57
+ // Pin the domain errors, then the handler types are inferred.
58
+ export const routeFactory = makeEffectRouteFactory<InvalidTodo>()((Respond) => ({
59
+ runtime,
60
+ errorHandlers: {
61
+ // A form error goes back to the form as data, with a 422.
62
+ InvalidTodo: (error) => Respond.early({ error: error.message }, 422),
63
+ },
64
+ }));
54
65
 
55
- **Any other error** a route consumes — a service-specific error that isn't a declared domain
56
- error — **must be handled** in the loader/action (caught or mapped), or `makeLoader`/`makeAction`
57
- fails to type-check with a message naming the unhandled error(s). This gives app-wide defaults for
58
- declared errors while enforcing explicit handling of feature/service-specific ones. (Likewise, an
59
- effect that requires a service neither the `runtime` nor the `requestContext` provides fails to
60
- type-check with a message naming the missing service.)
66
+ // Routes import this `Respond`. No library export has the same name.
67
+ export const { Respond } = routeFactory;
68
+ ```
61
69
 
62
- ## Usage
70
+ ### 2. Create the server app
63
71
 
64
- ### 1. Configure the factory once
72
+ `App` is a layer of routes on Effect's `HttpRouter`. `ReactRouter.layer` loads the build with your
73
+ route factory. `ReactRouter.routes` registers React Router as the catch-all route.
74
+ `ReactRouterAssets.layer` registers the files of the client build. Your own routes and middleware go
75
+ in the same `Layer.mergeAll`.
76
+
77
+ ```ts
78
+ // server/app.ts
79
+ import { Layer } from "effect";
80
+ import { ReactRouter, ReactRouterAssets } from "react-router-effect/server";
81
+
82
+ import { routeFactory } from "../app/lib/route-factory.server";
83
+
84
+ export const App = Layer.mergeAll(ReactRouter.routes, ReactRouterAssets.layer).pipe(
85
+ Layer.provide(
86
+ ReactRouter.layer({
87
+ build: () => import("virtual:react-router/server-build"),
88
+ factory: routeFactory,
89
+ mode: import.meta.env.MODE,
90
+ }),
91
+ ),
92
+ );
93
+ ```
94
+
95
+ ### 3. Serve it
96
+
97
+ `server.js` serves `App` with `HttpRouter.serve`, which also logs each request. When the server
98
+ starts, `App` loads the build and reads the client build. If the server cannot read the client
99
+ build, it fails to start with a `ClientBuildError` that names the resolved directory.
100
+
101
+ ```js
102
+ // server.js
103
+ import { createServer } from "node:http";
104
+
105
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
106
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
107
+ import { Layer } from "effect";
108
+ import { HttpRouter } from "effect/http";
109
+
110
+ import { App } from "./build/server/index.js";
111
+
112
+ NodeRuntime.runMain(
113
+ Layer.launch(
114
+ HttpRouter.serve(App).pipe(
115
+ Layer.provide(NodeHttpServer.layer(() => createServer(), { port: 3000 })),
116
+ ),
117
+ ),
118
+ );
119
+ ```
120
+
121
+ ### 4. Build the server
122
+
123
+ Make `server/app.ts` the entry of the server build. `react-router build` then writes `App` to
124
+ `build/server/index.js`.
125
+
126
+ ```ts
127
+ // vite.config.ts
128
+ import { reactRouter } from "@react-router/dev/vite";
129
+ import { defineConfig } from "vite";
130
+
131
+ export default defineConfig({
132
+ plugins: [reactRouter()],
133
+ environments: { ssr: { build: { rollupOptions: { input: "./server/app.ts" } } } },
134
+ });
135
+ ```
136
+
137
+ ```bash
138
+ react-router build
139
+ NODE_ENV=production node server.js # React's server build reads NODE_ENV
140
+ ```
141
+
142
+ For development with Vite, the Node example's `server.js` serves the app through
143
+ `ReactRouterVite.layer`. Read [Development with Vite](examples/node/README.md#development-with-vite).
144
+
145
+ ### 5. Write routes as Effects
146
+
147
+ A loader, action or middleware is an `Effect.fn`. It needs no wrapper. The adapter runs it with the
148
+ factory's runtime and error handlers.
149
+
150
+ ```ts
151
+ // app/routes/todos.tsx
152
+ import { Effect } from "effect";
153
+ import { data } from "react-router";
154
+ import type { EffectRouteArgs } from "react-router-effect";
155
+
156
+ import type { Route } from "./+types/todos";
157
+
158
+ import { Todos } from "../lib/services.server";
159
+
160
+ export const loader = Effect.fn("loader.todos")(function* () {
161
+ return { todos: yield* (yield* Todos).list };
162
+ });
163
+
164
+ // `InvalidTodo` is not handled here. The route factory turns it into `{ error }` data with a 422.
165
+ export const action = Effect.fn("action.todos")(function* ({
166
+ request,
167
+ }: EffectRouteArgs<Route.ActionArgs>) {
168
+ const form = yield* Effect.promise(() => request.formData());
169
+ const todo = yield* (yield* Todos).add(String(form.get("title")));
170
+ return data({ todo }, { status: 201 });
171
+ });
172
+ ```
173
+
174
+ `EffectRouteArgs` keeps hooks that take `typeof action` or `typeof loader` typed, such as
175
+ `useFetcher<typeof action>()`. [Plain Effect loaders, actions and
176
+ middleware](#plain-effect-loaders-actions-and-middleware) explains why. A route function that does
177
+ not return an Effect passes through to React Router unchanged, so promise loaders keep working in
178
+ the same app.
179
+
180
+ ## Platforms
181
+
182
+ `App` is the same layer on every platform. Only the file that serves it changes. Effect has two
183
+ functions that serve it:
184
+
185
+ - `HttpRouter.serve(App)` with a server layer, such as `NodeHttpServer.layer` on Node.
186
+ - `HttpRouter.toWebHandler(App)`, which returns a fetch handler, such as a Cloudflare Worker's.
187
+
188
+ | Platform | Example | Serves `App` with |
189
+ | ------------------ | -------------------------------------------- | --------------------------------------------- |
190
+ | Node | [`examples/node`](examples/node) | `HttpRouter.serve` and `NodeHttpServer.layer` |
191
+ | Cloudflare Workers | [`examples/cloudflare`](examples/cloudflare) | `HttpRouter.toWebHandler` |
192
+
193
+ The README of each example has the details of its platform. These rules apply on every platform:
194
+
195
+ - App-wide services go in the route factory's `runtime`. Read
196
+ [App-wide services with a runtime](#app-wide-services-with-a-runtime).
197
+ - Per-request services go in a router middleware. Read [Per-request services](#per-request-services).
198
+ - A platform service, such as a tracer, goes where the serve function reads it. `HttpRouter.serve`
199
+ reads the services that `Layer.provide` gives to its layer. `HttpRouter.toWebHandler` reads the
200
+ services that `Layer.provideMerge` adds to the layer that it gets.
201
+
202
+ ## Handle errors
203
+
204
+ An Effect can short-circuit in three ways, with the `Respond` helpers:
205
+
206
+ | Helper | Outcome | Where it lands |
207
+ | ------------------------------ | ------- | --------------------------------------------------- |
208
+ | `Respond.early(value, init?)` | recover | resolves with `data(value, init)` → `useLoaderData` |
209
+ | `Respond.throw(value, init?)` | throw | rejects with `data(value, init)` → error boundary |
210
+ | `Respond.redirect(url, init?)` | throw | rejects with a redirect `Response` |
211
+
212
+ Declare your app's domain errors as the type argument of `makeEffectRouteFactory`. You can register
213
+ one handler for each. A handler remaps its error. It returns one of these:
214
+
215
+ - A library route error: `Respond.early(...)`, `Respond.throw(...)` or `Respond.redirect(...)`.
216
+ - An `Effect`: `Effect.succeed(value)` recovers with `value`, and `Effect.fail(response)` throws a
217
+ `Response` or `DataWithResponseInit`.
65
218
 
66
219
  ```ts
67
- // app/route.server.ts
68
220
  import { Data, Effect } from "effect";
69
- import { makeEffectRouteFactory } from "react-router-effect";
70
221
 
71
222
  class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
72
223
  class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
73
224
  class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
74
225
 
75
- // Declare every error your app handles app-wide. `DbError` has no handler below,
76
- // so it falls through to the 500 default.
226
+ // `DbError` has no handler below, so it falls through to the 500 default.
77
227
  type DomainErrors = FormError | BadInputError | DbError;
78
228
 
79
- // Curried: pin the domain errors, then the handler types are inferred. The config
80
- // is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
81
- // so your handlers can recover/throw, and the factory hands back a single `Respond`
82
- // for your routes to import (no auto-import ambiguity with a library export).
83
- export const { makeLoader, makeAction, Respond } = makeEffectRouteFactory<DomainErrors>()(
84
- (Respond) => ({
85
- // App-specific helpers — merged onto the returned `Respond` (base helpers win):
86
- respond: {
87
- formError: (reply: SubmissionResponse) => new FormError({ reply }),
88
- },
89
- errorHandlers: {
90
- // keys autocomplete to your domain-error tags; `error` is typed from its key.
91
- // recover: short-circuit and hand the reply to the component
92
- FormError: (error) => Respond.early({ reply: error.reply }),
93
- // throw: send to the error boundary
94
- BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
95
- },
96
- }),
97
- );
229
+ export const routeFactory = makeEffectRouteFactory<DomainErrors>()((Respond) => ({
230
+ // App-specific helpers. The returned `Respond` has them next to the base helpers.
231
+ respond: {
232
+ formError: (reply: SubmissionResponse) => new FormError({ reply }),
233
+ },
234
+ errorHandlers: {
235
+ // Keys autocomplete to your domain-error tags, and `error` is typed from its key.
236
+ // Recover: pass the reply to the component.
237
+ FormError: (error) => Respond.early({ reply: error.reply }),
238
+ // Throw: send to the error boundary.
239
+ BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
240
+ },
241
+ }));
242
+ ```
243
+
244
+ A declared domain error can have no handler. If it implements `HttpServerRespondable`, it renders
245
+ its own response. A registered handler still takes precedence. Otherwise it falls through to a 500.
246
+
247
+ ```ts
248
+ import { HttpServerRespondable, HttpServerResponse } from "effect/http";
249
+
250
+ class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
251
+ [HttpServerRespondable.symbol]() {
252
+ return HttpServerResponse.json({ error: "Not authorized" }, { status: 403 }).pipe(Effect.orDie);
253
+ }
254
+ }
98
255
  ```
99
256
 
100
- > **One `Respond`, no annotations.** Your routes import the `Respond` returned here — there's
101
- > no library-level `Respond` to clash with it on auto-import. Handler keys autocomplete to your
102
- > declared domain-error tags, each `error` parameter is typed from its key, and the precise
103
- > recover types are derived from each handler's return.
257
+ Any other error that a route consumes must be handled in the route. This includes a
258
+ service-specific error that is not a declared domain error. Catch it or map it. This gives the app
259
+ defaults for declared errors. The errors of one feature or service need explicit handling.
260
+ [The route check](#the-route-check) reports an unhandled error at compile time.
104
261
 
105
- ### 2. Write loaders/actions as effects
262
+ ## Provide services
263
+
264
+ ### App-wide services with a runtime
265
+
266
+ The `runtime` is an Effect [`ManagedRuntime`](https://effect.website). The factory builds it once and
267
+ reuses it for every request. Route Effects require its services directly, with no `Effect.provide`
268
+ in the route. The runtime's services are the allowed requirements of a route Effect. A route that
269
+ requires another service is a compile error. Without a `runtime`, a route Effect must require
270
+ nothing.
106
271
 
107
272
  ```ts
108
- // app/routes/profile.ts
109
- import { Effect } from "effect";
110
- import { makeLoader, Respond } from "../route.server.ts";
111
- import type { Route } from "./+types/profile";
273
+ export const loader = Effect.fn("loader.profile")(function* ({
274
+ params,
275
+ }: EffectRouteArgs<Route.LoaderArgs>) {
276
+ const svc = yield* MyService; // the runtime provides MyService
277
+ return { profile: yield* svc.load(params.id) };
278
+ });
279
+ ```
112
280
 
113
- const loaderEffect = ({ params }: Route.LoaderArgs) =>
114
- Effect.gen(function* () {
115
- const profile = yield* getProfile(params.id); // may fail with FormError / BadInputError
116
- if (!profile.public) {
117
- yield* Respond.early({ restricted: true }); // recover with typed data
118
- }
119
- return { profile };
120
- });
281
+ `errorHandlers` is optional. A factory can have a runtime only, or nothing:
121
282
 
122
- export const loader = makeLoader((args: Route.LoaderArgs) => loaderEffect(args));
283
+ ```ts
284
+ makeEffectRouteFactory()(() => ({ runtime }));
285
+ makeEffectRouteFactory()(() => ({}));
123
286
  ```
124
287
 
125
- The resolved type is computed from the effect and your handlers:
288
+ ### Per-request services
289
+
290
+ The runtime provides app-wide services. A request-scoped service is the current user, a request id,
291
+ or a transaction for one request. There are two ways to provide one.
292
+
293
+ A route-scoped router middleware on `ReactRouter.routes` provides services to React Router's
294
+ requests. Every middleware, loader and action sees them, and no React Router middleware has to set
295
+ them:
126
296
 
127
297
  ```ts
128
- type LoaderData = Route.ComponentProps["loaderData"];
129
- // { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
130
- // ( BadInputError throws, so it never appears here )
298
+ // server/app.ts
299
+ const RequestServices = HttpRouter.middleware((app) =>
300
+ Effect.provideServiceEffect(
301
+ app,
302
+ RequestInfo,
303
+ HttpServerRequest.HttpServerRequest.use((request) =>
304
+ Effect.succeed({
305
+ id: request.headers["x-request-id"] ?? crypto.randomUUID(),
306
+ startedAt: Date.now(),
307
+ }),
308
+ ),
309
+ ),
310
+ ).layer;
311
+
312
+ export const App = Layer.mergeAll(
313
+ ReactRouter.routes.pipe(Layer.provide(RequestServices)),
314
+ ReactRouterAssets.layer,
315
+ ).pipe(
316
+ Layer.provide(
317
+ ReactRouter.layer({
318
+ build: () => import("virtual:react-router/server-build"),
319
+ factory: routeFactory,
320
+ mode: import.meta.env.MODE,
321
+ }),
322
+ ),
323
+ );
131
324
  ```
132
325
 
133
- ### 3. Write middleware as effects
326
+ A [middleware](https://reactrouter.com/how-to/middleware) can also provide services. An effect
327
+ middleware provides them around `next()`, and everything downstream of `next()` can require them:
134
328
 
135
- `makeMiddleware` wraps a [middleware](https://reactrouter.com/how-to/middleware) function the
136
- same way, wired to the same `errorHandlers`. Pin it to the route's generated
137
- `Route.MiddlewareFunction` — middleware has no separate args-only type, so the whole function
138
- type is the type argument — or omit it to default to React Router's own `MiddlewareFunction`:
329
+ ```ts
330
+ // app/root.tsx
331
+ export const middleware = [
332
+ Effect.fn("middleware.visitor")(function* ({
333
+ request,
334
+ next,
335
+ }: EffectMiddlewareArgs<Route.MiddlewareFunction>) {
336
+ const name = yield* readVisitor(request);
337
+ return yield* next().pipe(
338
+ Effect.provideService(Visitor, { name }),
339
+ Effect.annotateLogs({ visitor: name }),
340
+ );
341
+ }),
342
+ ];
343
+ ```
344
+
345
+ Tell the factory which services the request provides with a context key:
139
346
 
140
347
  ```ts
141
- // app/routes/profile.ts
142
- import { Effect } from "effect";
143
- import { makeMiddleware, Respond } from "../route.server.ts";
144
- import type { Route } from "./+types/profile";
348
+ // app/lib/route-factory.server.ts
349
+ import { createContext } from "react-router";
350
+ import type { RequestContextKey } from "react-router-effect";
145
351
 
146
- export const middleware: Route.MiddlewareFunction[] = [
147
- makeMiddleware<Route.MiddlewareFunction>(({ request }, next) =>
148
- Effect.gen(function* () {
149
- const user = yield* getUser(request); // may fail with FormError / BadInputError
150
- if (!user) yield* Respond.redirect("/login"); // throw → redirect, same as a loader
151
- return yield* next(); // run the rest of the chain
352
+ export const requestContext: RequestContextKey<RequestInfo | Visitor> = createContext();
353
+
354
+ export const routeFactory = makeEffectRouteFactory<DomainErrors>()(() => ({
355
+ runtime,
356
+ requestContext,
357
+ }));
358
+ ```
359
+
360
+ A route Effect can then require the runtime's services and the request's services. Any other
361
+ requirement is a compile error. `RequestContextKey<ReqServices>` is an alias of
362
+ `RouterContext<Context.Context<ReqServices>>`.
363
+
364
+ The adapter sets this key to the request fiber's context before React Router runs. That context
365
+ holds the server span, `HttpServerRequest` and the services of the router middleware. Do not set the
366
+ key to a new context in a React Router middleware, as an app without the adapter does. A new context
367
+ replaces those services for every function after that middleware. Provide request services with a
368
+ router middleware or an effect middleware, as this section shows.
369
+
370
+ ## The adapter
371
+
372
+ `react-router-effect/server` is a React Router adapter for Effect's HTTP router, on every platform
373
+ that the router runs on. It is the Effect counterpart of `@react-router/express`. It
374
+ handles everything between an `HttpServerRequest` and React Router. That is the Web `Request` and
375
+ its abort signal, the route factory, and the client build. It also converts React Router's
376
+ `Response`, with its streamed body and its cookies. The app keeps its own server, middleware and
377
+ routes.
378
+
379
+ The adapter's API is unstable, and a minor version can change it. Every export of
380
+ `react-router-effect/server` and `react-router-effect/vite` has the JSDoc tag `@stability unstable`.
381
+ The route factory and the other exports of `react-router-effect` are stable.
382
+
383
+ React Router keeps doing what it does for any other server adapter: documents, `.data` requests,
384
+ streamed promises, resource routes, the lazy route manifest and middleware. Before React Router
385
+ sees the build, the adapter runs every loader, action and middleware through your route factory:
386
+
387
+ - A route function that returns an Effect runs with the factory's runtime and request context.
388
+ Its failures go through the factory's error handlers.
389
+ - Any other route function passes through. Promise loaders and `makeLoader` wrappers keep working.
390
+
391
+ The layers:
392
+
393
+ - `ReactRouter.layer(options)` provides the `ReactRouter` service. Its options are these:
394
+ - `build` is the server build, or a function that loads it.
395
+ - `factory` is the route factory.
396
+ - `mode` is React Router's server mode, and you must give it. In a Vite app, pass
397
+ `import.meta.env.MODE`. React Router runs in production mode for any mode but `development`
398
+ and `test`, and so does the layer. At layer build, the layer loads a build value once, or a
399
+ build function in production mode. In `development` and `test`, a build function loads for
400
+ each request.
401
+ - `loadContext` makes React Router's load context for each request. Read
402
+ [The load context](#the-load-context).
403
+ - `ReactRouter.routes` registers React Router as the catch-all route on `*`.
404
+ - `ReactRouterAssets.layer` registers the files of the client build. Read
405
+ [The client build](#the-client-build).
406
+ - `ReactRouterVite.layer`, from `react-router-effect/vite`, serves the app in development on Node.
407
+ Read [Development with Vite](examples/node/README.md#development-with-vite).
408
+
409
+ Provide one value of `ReactRouter.layer` to every layer that reads the service, as `App` does with
410
+ `Layer.provide`. Two values load the build twice.
411
+
412
+ ### The load context
413
+
414
+ `loadContext` does for the adapter what `getLoadContext` does for `@react-router/express`. It
415
+ returns an Effect of a `RouterContextProvider`. Plain loaders and actions, and `handleRequest` in
416
+ `entry.server.tsx`, read its values with `context.get`. Route Effects read services from their
417
+ Effect context. So give route Effects services with router middleware, not with `loadContext`.
418
+
419
+ ```ts
420
+ ReactRouter.layer({
421
+ build: () => import("virtual:react-router/server-build"),
422
+ factory: routeFactory,
423
+ mode: import.meta.env.MODE,
424
+ loadContext: (request) =>
425
+ Effect.sync(() => {
426
+ const context = new RouterContextProvider();
427
+ context.set(localeKey, request.headers["accept-language"] ?? "en");
428
+ return context;
429
+ }),
430
+ });
431
+ ```
432
+
433
+ The Effect gets the services of the layer and the services of the request. The services of the
434
+ request, such as its scope, win. The adapter adds its own keys to the provider. If the Effect dies,
435
+ the client gets an empty 500.
436
+
437
+ ### Routes and middleware
438
+
439
+ The route that a request matches decides which middleware runs on it. Effect has three kinds of
440
+ middleware:
441
+
442
+ - A server middleware, the `middleware` option of `HttpRouter.serve`, wraps every request. It also
443
+ wraps the send of the response, so a change to the response with `Effect.map` does not reach the
444
+ client. `HttpMiddleware.compression` works there.
445
+ - A global router middleware, `HttpRouter.middleware(fn, { global: true })`, wraps every route.
446
+ - A route-scoped middleware, `HttpRouter.middleware(fn).layer`, applies only to the routes of the
447
+ layers that you provide it to.
448
+
449
+ Route-scoped middleware selects the middleware of each kind of request. `HttpRouter.serve` logs
450
+ every request. To leave file requests out of that log, provide `HttpRouter.disableLogger` to the
451
+ assets layer only:
452
+
453
+ ```ts
454
+ export const App = Layer.mergeAll(
455
+ ReactRouter.routes.pipe(Layer.provide(RequestServices)),
456
+ ReactRouterAssets.layer.pipe(Layer.provide(HttpRouter.disableLogger)),
457
+ HttpRouter.middleware(HttpMiddleware.compression(), { global: true }),
458
+ ).pipe(
459
+ Layer.provide(
460
+ ReactRouter.layer({
461
+ build: () => import("virtual:react-router/server-build"),
462
+ factory: routeFactory,
463
+ mode: import.meta.env.MODE,
152
464
  }),
153
465
  ),
154
- ];
466
+ );
467
+ ```
468
+
469
+ The order of two route-scoped middleware layers on one route is not fixed. If the order matters,
470
+ join them into one middleware with `combine`:
471
+ `HttpRouter.middleware(first).combine(HttpRouter.middleware(second)).layer`.
472
+
473
+ These rules apply to the routes:
474
+
475
+ - An exact route wins over a route with a parameter, and both win over a wildcard. So your own
476
+ routes and the files of the client build win over React Router's `*`.
477
+ - A route of your own on `*` or `/*`, for a method that React Router's route has, collides with it.
478
+ Then the server fails to start. A route of your own with the path of a file of the client build
479
+ fails the same way.
480
+ - If a HEAD request matches no HEAD route, it uses the GET route of its path. `ReactRouter.routes`
481
+ registers no HEAD route. So a GET route of your own answers HEAD, as it does in Express.
482
+ - React Router's route takes the methods of `HttpMethod.all`: GET, POST, PUT, DELETE, PATCH,
483
+ OPTIONS, TRACE and QUERY, and HEAD through GET. A request with another method, such as PROPFIND,
484
+ gets a 404 from the router.
485
+ - Register `ReactRouter.routes` at the root of the router. Under a prefix, React Router does not
486
+ get the path that it expects. To serve the app under a path, set React Router's `basename`.
487
+ - The router ignores the case of the path. On a file system that is case-sensitive, a request for a
488
+ file with the wrong case matches the file's route and gets a 404.
489
+
490
+ ### The client build
491
+
492
+ `ReactRouterAssets.layer` serves `build.assetsBuildDirectory` under `build.publicPath`, as the two
493
+ `express.static` lines of React Router's Express template do. It needs the platform's file system,
494
+ which `NodeHttpServer.layer` provides. A Cloudflare Worker serves the client build from its static
495
+ assets, and does not use this layer.
496
+
497
+ - The hashed directory is the directory of the build's manifest URL, such as `/assets`. React
498
+ Router's build writes files with a content hash in their names there. The layer caches them for a
499
+ year as immutable. A request under the hashed directory gets a 404 for a file that the directory
500
+ does not have. A path with `..` does not get out of the hashed directory.
501
+ - Each other file gets its own route for GET and HEAD, and the layer caches it for an hour. At
502
+ server start, the layer reads the client build. If a file is added outside the hashed directory
503
+ later, the layer serves it only after a restart.
504
+ - A directory with an `index.html`, such as a prerendered page, serves that file. Its path without
505
+ the trailing slash redirects to the path with it. A root `index.html`, such as a prerendered home
506
+ page, serves `/`, and React Router's catch-all yields to it.
507
+ - If the manifest URL is not under `publicPath`, or it is at the root of `publicPath`, there is no
508
+ hashed directory. Then the layer caches every file for an hour.
509
+ - The layer serves no dotfiles, such as `.vite/manifest.json`, as `express.static` does.
510
+ - The layer registers nothing for a build that loads for each request, such as a development build.
511
+ Vite serves the client there. It also registers nothing for a `publicPath` on another origin.
512
+ - `assetsBuildDirectory` is relative to the working directory of the server. If the layer cannot
513
+ read it, the server fails to start with a `ClientBuildError` that names the resolved directory.
514
+ If a file name has a `*`, the server fails to start with a `ClientBuildError` too, because the
515
+ router cannot match that name.
516
+
517
+ ### Requests
518
+
519
+ The Web request comes from `HttpServerRequest.toWeb`, body included. On Effect's Node server, the
520
+ request has the changes that middleware makes to the `HttpServerRequest`, such as the headers of
521
+ `HttpMiddleware.xForwardedHeaders`. On a Web server, such as a Cloudflare Worker, React Router gets
522
+ the runtime's own request. The README of each example describes the requests of its platform.
523
+
524
+ If the client leaves while React Router runs or while the body streams, the response ends early, and
525
+ the request signal aborts. A completed response never aborts it. On Cloudflare Workers, the abort
526
+ comes from Cloudflare's own request signal. For the conditions of that abort, read the
527
+ [Cloudflare example](examples/cloudflare/README.md#requests).
528
+
529
+ React Router logs and answers the errors of route code. If the adapter's own steps fail, for example
530
+ a `loadContext` that dies, the adapter logs the error with `Effect.logError`. Then it answers an
531
+ empty 500. The log goes to the loggers of the app's Effect runtime.
532
+
533
+ ### Cookies
534
+
535
+ Set cookies from route code with React Router's cookie helpers, `createCookie` and the session
536
+ storages, as in any React Router app. The adapter converts React Router's `Set-Cookie` values into
537
+ the response's `cookies`, as `HttpServerResponse.fromWeb` does. A router middleware on
538
+ `ReactRouter.routes` can read and change them like any other cookie. A response that replaces React
539
+ Router's has none of them.
540
+
541
+ Effect keeps one cookie per name and only the attributes it knows: `Max-Age`, `Domain`, `Path`,
542
+ `Expires`, `HttpOnly`, `Secure`, `Partitioned`, `Priority` and `SameSite`. One response can set the
543
+ same cookie name twice, for example to expire it on one path and set it on another. Then Effect
544
+ sends only the last one.
545
+
546
+ Effect accepts only cookie names that are RFC 6265 tokens: letters, digits and
547
+ ``!#$%&'*+-.^_`|~``. React Router's `createCookie` and session storages also accept other names,
548
+ such as `app/session` or `user:prefs`. The adapter does not send a cookie with such a name. It logs
549
+ an error with the cookie's name instead. Give every cookie a token name.
550
+
551
+ ### Content type
552
+
553
+ Effect gives a streamed body without a content type `application/octet-stream`. A resource route
554
+ that returns such a body sends that type, and the browser downloads the response. Examples are a
555
+ `ReadableStream`, bytes, or the body of another service's response. With Express, the response has
556
+ no content type, and the browser guesses one. Set `Content-Type` in the route:
557
+
558
+ ```ts
559
+ return new Response(Readable.toWeb(file) as ReadableStream, {
560
+ headers: { "content-type": "application/pdf" },
561
+ });
562
+ ```
563
+
564
+ ### Tracing
565
+
566
+ When the request has a server span, the adapter traces React Router. If the app provides a tracer
567
+ that exports spans, such as `OtelTracer`, Effect's HTTP server makes the server span. Give the
568
+ tracer where the serve function reads it, as [Platforms](#platforms) tells. Effect serves each
569
+ request with the services of that layer. A tracer that the serve function does not read makes no
570
+ server span, so the adapter adds no spans. The README of each example shows where its tracer goes.
571
+
572
+ Each middleware, loader and action runs in a span of its own, such as
573
+ `react-router.loader routes/todos`.
574
+ The spans nest as React Router calls the functions. This is the example's `GET /todos`:
575
+
576
+ ```text
577
+ GET the server span, with http.route=/todos
578
+ └─ react-router.middleware root
579
+ └─ middleware.visitor the Effect middleware's own span, around next()
580
+ ├─ react-router.loader root
581
+ │ └─ loader.root the route Effect's span
582
+ └─ react-router.loader routes/todos
583
+ └─ loader.todos
155
584
  ```
156
585
 
157
- `next` is handed to the effect as `() => Effect.Effect<Result, FailureResponse>` (React Router's
158
- own `next: () => Promise<Result>`, wrapped) — call it with `yield*` to run downstream
159
- loaders/middleware. A downstream throw (e.g. a nested loader's redirect) surfaces as a typed
160
- failure rather than a rejected promise, so it flows through the same recover/throw handling as
161
- a loader or action.
586
+ A middleware's span covers everything its `next()` runs: the next middleware, then the loaders and
587
+ the action. Route Effects nest under the span of their function. Effect's OpenTelemetry tracer makes
588
+ the current Effect span the active OpenTelemetry context. So the clients that an OpenTelemetry
589
+ instrumentation traces nest there too, such as a database driver that a promise loader calls.
590
+ Without a server span, or with Effect's native tracer, whose spans go nowhere, the adapter adds no
591
+ spans and no cost.
162
592
 
163
- ### Self-rendering domain errors
593
+ The adapter sets `http.route` on the server span to the pattern of the matched routes, such as
594
+ `/todos/:id`, once per request. It does this also for a page that runs no route function. A router
595
+ middleware that opens a span of its own does not change that: the pattern still goes on the server
596
+ span. A request that matches no route keeps the router's `*`. `ReactRouter.layer` reads the tracer
597
+ once, at layer build. So it does not see a tracer that the app provides only inside a request.
164
598
 
165
- If a domain error implements `HttpServerRespondable`, you don't need to register a handler —
166
- it renders its own response:
599
+ ## Route functions
600
+
601
+ ### Plain Effect loaders, actions and middleware
167
602
 
168
603
  ```ts
169
- import { HttpServerRespondable, HttpServerResponse } from "effect/http";
604
+ export const loader = Effect.fn("loader.dashboard")(function* () {
605
+ const { organization } = yield* AuthContext.getScope;
606
+ return { organization };
607
+ });
608
+
609
+ export const action = Effect.fn("action.createTax")(function* ({
610
+ request,
611
+ }: EffectRouteArgs<Route.ActionArgs>) {
612
+ const submission = yield* parseSubmission(request);
613
+ return data({ reply: submission.reply() }, { status: 201 });
614
+ });
615
+
616
+ export const middleware = [
617
+ Effect.fn("middleware.adminOnly")(function* ({
618
+ next,
619
+ }: EffectMiddlewareArgs<Route.MiddlewareFunction>) {
620
+ const user = yield* AuthContext.getUser;
621
+ if (user.role !== "admin") return redirect("/settings");
622
+ return yield* next().pipe(Effect.annotateLogs({ userId: user.id }));
623
+ }),
624
+ ];
625
+ ```
170
626
 
171
- class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
172
- [HttpServerRespondable.symbol]() {
173
- return HttpServerResponse.json({ error: "Not authorized" }, { status: 403 }).pipe(Effect.orDie);
627
+ - Route data: Effects are branded with React Router's `unstable_SerializesTo`. So
628
+ `Route.ComponentProps` types the data of an Effect loader or action, including what the factory
629
+ recovers.
630
+ - Hooks that take `typeof loader` or `typeof action`, such as `useFetcher<typeof action>()`,
631
+ read the data through React Router's `SerializeFrom`. It treats a function as a client
632
+ function when its argument has the members of client loader args, and gives client functions
633
+ their raw return value. Typegen's `Route.LoaderArgs` and `Route.ActionArgs` have those same
634
+ members, so for an Effect the hook would type the data as the Effect itself.
635
+ - Take the args through `EffectRouteArgs<Route.LoaderArgs>`. It makes `pattern` optional, so the
636
+ argument no longer matches and the hook reads the brand. A loader without args needs nothing.
637
+ - `RouteData<typeof loader>` reads the brand directly and applies React Router's serialization.
638
+ The brand records the data before serialization: it has to keep Effect's subtype order, and
639
+ React Router's `Serialize` does not.
640
+ - Middleware: an effect middleware gets `next` as an effect in its args. Services, log annotations
641
+ and the span that it provides around `next()` reach every route Effect downstream. `context.set`
642
+ keeps working. You cannot annotate a route array with an effect middleware as
643
+ `Route.MiddlewareFunction[]`.
644
+ - Action metadata: `actionMeta` in the factory config is read after every action resolves. The
645
+ adapter attaches it to the action's data under `__meta`.
646
+ - Tests: `routeFactory.handlers.loader(loader)(args)` runs a route function as the server does.
647
+
648
+ ### Register what the factory recovers
649
+
650
+ A plain Effect loader cannot see the factory's handlers. Declare what they recover once:
651
+
652
+ ```ts
653
+ declare module "react-router-effect" {
654
+ interface Register {
655
+ recover: { FormValidationError: { reply: SubmissionResponse } };
656
+ actionMeta: { realtimeEventIds: ReadonlyArray<string> };
174
657
  }
175
658
  }
176
659
  ```
177
660
 
178
- A registered handler, if present, still takes precedence over the error's own response.
661
+ Declare the types directly. Reading them from `typeof routeFactory` is circular.
662
+
663
+ ### The route check
179
664
 
180
- ### Providing services with a runtime
665
+ `RouteEffectCheck` reads the route modules that React Router's typegen registers. It fails to
666
+ compile in three cases:
181
667
 
182
- Pass a `runtime` (an effect [`ManagedRuntime`](https://effect.website)) and your loaders/actions
183
- may require its services directly — no per-call `Effect.provide`. The runtime is built once and
184
- reused for every request:
668
+ - A route Effect can fail with an error that the factory does not handle.
669
+ - A route Effect needs a service that the runtime and the request context do not provide.
670
+ - `Register` disagrees with the factory.
185
671
 
186
672
  ```ts
187
- // app/runtime.server.ts
188
- import { ManagedRuntime } from "effect";
189
- export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
673
+ import type { RouteEffectCheck } from "react-router-effect/server";
190
674
 
191
- // app/route.server.ts
192
- export const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
193
- runtime: appRuntime,
194
- errorHandlers: { ... },
195
- }));
675
+ export const routeEffects: RouteEffectCheck<typeof routeFactory> = {};
676
+ ```
677
+
678
+ ```text
679
+ Type '{}' is missing the following properties from type ...:
680
+ "routes/billing#loader must handle PlaidApiError",
681
+ "routes/admin#middleware needs a provider for app/Session"
682
+ ```
683
+
684
+ ### Requirements and limits
196
685
 
197
- // app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
198
- const loader = makeLoader((args: Route.LoaderArgs) =>
686
+ - Effect 4.1 or later, and `skipLibCheck`. The brand augments `Effect`, and TypeScript cannot test
687
+ the variance annotations of that augmentation.
688
+ - For the adapter, React Router 8.3 or later. The route factory, `makeLoader`, `makeAction` and
689
+ `makeMiddleware` also support React Router 7.16 or later.
690
+ - A route module export must be a function. A bare `Effect` value is not a loader.
691
+ - Prerendering (`prerender`) and RSC mode call the build without the adapter.
692
+
693
+ ## Without the Effect HTTP server
694
+
695
+ If your app serves React Router from Express, `@react-router/node`, a serverless platform or another
696
+ server, the adapter does not apply. The same route factory still returns `makeLoader`, `makeAction`
697
+ and `makeMiddleware`. Each wraps one route function. The wrapper runs the Effect with the factory's
698
+ runtime and error handlers, and returns a function that React Router calls.
699
+
700
+ ```ts
701
+ // app/routes/profile.ts
702
+ import { Effect } from "effect";
703
+ import { makeLoader, Respond } from "../route.server.ts";
704
+ import type { Route } from "./+types/profile";
705
+
706
+ export const loader = makeLoader((args: Route.LoaderArgs) =>
199
707
  Effect.gen(function* () {
200
- const svc = yield* MyService;
201
- return { profile: yield* svc.load(args.params.id) };
708
+ const profile = yield* getProfile(args.params.id); // can fail with FormError / BadInputError
709
+ if (!profile.public) {
710
+ yield* Respond.early({ restricted: true }); // recover with typed data
711
+ }
712
+ return { profile };
202
713
  }),
203
714
  );
204
715
  ```
205
716
 
206
- The runtime's services become the effect's allowed requirement channel: requiring a service the
207
- runtime provides type-checks, while requiring one it _doesn't_ is a compile error. With no
208
- `runtime`, effects must require nothing.
717
+ Take `makeLoader`, `makeAction`, `makeMiddleware` and `Respond` from the factory:
718
+
719
+ ```ts
720
+ // app/route.server.ts
721
+ export const { makeLoader, makeAction, makeMiddleware, Respond } =
722
+ makeEffectRouteFactory<DomainErrors>()((Respond) => ({
723
+ runtime: appRuntime,
724
+ errorHandlers: {/* ... */},
725
+ }));
726
+ ```
209
727
 
210
- `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
728
+ `makeLoader` and `makeAction` are the same wrapper. The wrapper computes the resolved type from the
729
+ effect and your handlers:
211
730
 
212
731
  ```ts
213
- makeEffectRouteFactory()(() => ({ runtime }));
214
- makeEffectRouteFactory()(() => ({}));
732
+ type LoaderData = Route.ComponentProps["loaderData"];
733
+ // { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
734
+ // ( BadInputError throws, so it never appears here )
215
735
  ```
216
736
 
217
- ### Per-request services from middleware
737
+ Without the adapter, an unhandled error is a compile error at the `makeLoader` or `makeAction` call.
738
+ The message lists the unhandled errors. An effect can also require a service that neither the
739
+ `runtime` nor the `requestContext` provides. That fails the same way, and the message lists the
740
+ missing service.
218
741
 
219
- The runtime provides app-wide services. For **request-scoped** services — the current user, a
220
- request id, a per-request transaction — use [middleware](https://reactrouter.com/how-to/middleware).
221
- Middleware sets a fresh effect `Context` on a React Router context key each request; the factory's
222
- `requestContext` reads it and provides those services to the loader/action:
742
+ ### Middleware
743
+
744
+ `makeMiddleware` wraps a [middleware](https://reactrouter.com/how-to/middleware) function the same
745
+ way, with the same `errorHandlers`. Pin it to the route's generated `Route.MiddlewareFunction`.
746
+ Middleware has no separate args-only type, so the full function type is the type argument. If you
747
+ omit the type argument, it defaults to React Router's own `MiddlewareFunction`:
748
+
749
+ ```ts
750
+ import { Effect } from "effect";
751
+ import { makeMiddleware, Respond } from "../route.server.ts";
752
+ import type { Route } from "./+types/profile";
753
+
754
+ export const middleware: Route.MiddlewareFunction[] = [
755
+ makeMiddleware<Route.MiddlewareFunction>(({ request }, next) =>
756
+ Effect.gen(function* () {
757
+ const user = yield* getUser(request); // can fail with FormError / BadInputError
758
+ if (!user) yield* Respond.redirect("/login"); // throw → redirect, same as a loader
759
+ return yield* next(); // run the rest of the chain
760
+ }),
761
+ ),
762
+ ];
763
+ ```
764
+
765
+ The effect gets `next` as `() => Effect.Effect<Result, FailureResponse>`. It is React Router's own
766
+ `next: () => Promise<Result>`, wrapped. Call it with `yield*` to run the downstream loaders and
767
+ middleware. A downstream throw, such as a nested loader's redirect, becomes a typed failure instead
768
+ of a rejected promise. It flows through the same recover and throw handling as a loader or action.
769
+
770
+ ### Per-request services
771
+
772
+ Without the adapter, no router middleware runs before React Router. Set the context key in a plain
773
+ React Router middleware. The factory's `requestContext` reads it, and provides those services to the loader or
774
+ action. With the adapter, do not set the key this way, as
775
+ [Per-request services](#per-request-services) tells. This is the pattern without the adapter:
223
776
 
224
777
  ```ts
225
778
  // app/request-context.server.ts
@@ -227,17 +780,13 @@ import { Context } from "effect";
227
780
  import { createContext } from "react-router";
228
781
  import type { RequestContextKey } from "react-router-effect";
229
782
 
230
- class RequestContext extends Context.Service<
231
- RequestContext,
232
- {
233
- readonly userId: string;
234
- }
235
- >()("app/RequestContext") {}
783
+ class RequestContext extends Context.Service<RequestContext, { readonly userId: string }>()(
784
+ "app/RequestContext",
785
+ ) {}
236
786
 
237
- // `requestContext` is a plain React Router context key — `createContext` is RR's own.
238
787
  export const requestContext: RequestContextKey<RequestContext> = createContext();
239
788
 
240
- // app/routes/profile.ts — middleware sets a fresh value per request:
789
+ // app/routes/profile.ts: a middleware sets a fresh value for each request.
241
790
  export const middleware: Route.MiddlewareFunction[] = [
242
791
  ({ context, request }, next) => {
243
792
  context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
@@ -245,13 +794,13 @@ export const middleware: Route.MiddlewareFunction[] = [
245
794
  },
246
795
  ];
247
796
 
248
- // app/route.server.ts — wire the same key into the factory:
797
+ // app/route.server.ts: wire the same key into the factory.
249
798
  export const { makeLoader } = makeEffectRouteFactory<DomainErrors>()(() => ({
250
799
  runtime: appRuntime,
251
800
  requestContext,
252
801
  }));
253
802
 
254
- // the loader requires RequestContext directly — no provide, fresh each request:
803
+ // The loader requires RequestContext directly, with a fresh value for each request.
255
804
  export const loader = makeLoader(() =>
256
805
  Effect.gen(function* () {
257
806
  const { userId } = yield* RequestContext;
@@ -260,26 +809,30 @@ export const loader = makeLoader(() =>
260
809
  );
261
810
  ```
262
811
 
263
- Effects may now require both the runtime's services and the request context's; requiring anything
264
- else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
265
- `RouterContext<Context.Context<ReqServices>>` — sugar for annotating the key, nothing more.
266
-
267
812
  ## API
268
813
 
269
- - **`makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
270
- → `{ makeLoader, makeAction, makeMiddleware, Respond }` (`makeLoader`/`makeAction` are the same
271
- wrapper; `makeMiddleware` wraps a `Route.MiddlewareFunction`, defaulting to react-router's own
272
- `MiddlewareFunction`). The config is a _builder_ that receives the base `Respond`; all its fields
273
- are optional. The returned `Respond` is the base helpers merged with your `respond` extensions
274
- (base helpers win). A non-domain error left in a handler's error channel — or a required service
275
- that neither the `runtime` nor the `requestContext` provides — is a compile error.
276
- - **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
277
- effect context (`RouterContext<Context.Context<ReqServices>>`).
278
- - **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
279
- `respond` helpers you add.
280
- - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
281
- route errors, and **`isRouteError`** to narrow them.
282
- - **`ErrorHandler<Err>`** — the handler signature type.
814
+ - `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?, actionMeta? }))`
815
+ returns `{ makeLoader, makeAction, makeMiddleware, Respond, handlers }`. The config is a builder
816
+ that receives the base `Respond`. All its fields are optional. The returned `Respond` is the base
817
+ helpers merged with your `respond` extensions, and the base helpers win. `makeLoader` and
818
+ `makeAction` are the same wrapper. `makeMiddleware` wraps a `Route.MiddlewareFunction`, and
819
+ defaults to React Router's own `MiddlewareFunction`.
820
+ - `handlers` (`loader`, `action`, `middleware`, `requestContext`) is the runner that the server
821
+ adapter applies to every route module.
822
+ - `Respond` has `early` (recover), `throw` and `redirect`, and the `respond` helpers that you add.
823
+ - `ReturnableDataError`, `ThrowableDataError` and `ThrowableRedirectError` are the library route
824
+ errors. `isRouteError` narrows them.
825
+ - `ErrorHandler<Err>` is the handler signature type.
826
+ - `RequestContextKey<ReqServices>` is the type of the React Router context key for a per-request
827
+ effect context.
828
+ - `EffectRouteArgs<Args>`, `EffectMiddlewareArgs<Middleware>`, `RouteData<F>` and `Register` are the
829
+ types of plain Effect routes.
830
+ - `react-router-effect/server` (unstable) exports `ReactRouter`, with `ReactRouter.layer`, `ReactRouter.routes`
831
+ and the `ReactRouter.Options` type, and `ReactRouterAssets`, with `ReactRouterAssets.layer` and
832
+ `ReactRouterAssets.ClientBuildError`. It also exports the types `RouteEffectCheck` and
833
+ `RouteEffectFailures`.
834
+ - `react-router-effect/vite` (unstable) exports `ReactRouterVite.layer` and the `ReactRouterVite.ViteDevServer`
835
+ type, for development on Node. It needs `@effect/platform-node`.
283
836
 
284
837
  ## Development
285
838