Engineering noteResearch & Engineering
Part of: Cloudflare Workers
Two Workers, One React Router 8 App: Decide at the Boundary
We build two Workers from one React Router app, pass programme identity through typed context, and use stored rows for scheduled work.
The programme
On this page
We run TEEI Language and TEEI Mentorship from one React Router 8 source tree. We share booking, video, messages and a database, but build and deploy each product as a separate Cloudflare Worker. We still need the right programme for every request. If we get it wrong, we could show a learner the wrong product or file a booking under the wrong programme. We resolve the programme at the Worker boundary and pass it to loaders and actions through typed context. For scheduled work, we take the programme from the stored row.
Using the same request context, we cut session lookups on three signed-in Language pages from three per navigation to one. That is a count of calls in our development notes from 31 July 2026, not a production benchmark. In counting the calls, we found a loader nobody had listed. Underneath it all, the build target, the programme of a request and the programme stored on a record answer different questions, and we keep them apart. How TEEI Runs Its Platforms on Cloudflare Workers tells the story from the account down: the Workers, their bindings, their placement next to the database and how we deploy them.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
Two products, two builds
TEEI Language helps adult learners practise a language through one-to-one conversations with approved volunteers and through group sessions. TEEI Mentorship connects a person seeking career guidance with an approved volunteer mentor who has relevant professional experience. Skills Academy, where volunteers teach workshops, clinics and classes, is a programme of its own on the same platform, served on its own hostname by the Mentorship Worker. We follow the rule in our platform documentation: “The engine is shared. Each programme module decides whether a mechanism is available, how it is described, which data makes a profile listable, and which programme-specific effects may run.”
React Router 8 has no adapter option, so we integrate through the Cloudflare Vite plugin and a Worker entry of our own. One build variable names the target, Language or Mentorship. From it we pick the matching Wrangler file and a separate output directory, so a build of one target cannot overwrite the other’s artefact in the middle of a deploy. We serialise builds of the same target, because its staging and production builds share that directory, and we reject an ordinary deploy build unless its target is exactly one of the names we know. We never fall back to Language.
We choose the Cloudflare environment with CLOUDFLARE_ENV before building; we cannot switch it when deploying the artefact. Cloudflare’s documentation says that “specifying CLOUDFLARE_ENV when running vite preview or wrangler deploy will have no effect”, because the build writes an output configuration that, in the words of Cloudflare’s changelog, is “flattened to only contain the active environment”. So we choose the environment in the build script and nowhere else, we treat an unknown environment as fatal rather than picking a default, and we never promote a staging build to production: production gets a build of its own.
The two Workers share the database but not their live presence. We give each Worker its own namespace of the LivePresence Durable Object; Cloudflare’s documentation on Durable Objects and environments notes that a binding attached to a different Worker name “will therefore access different Durable Objects with different persistent storage”. Marking a volunteer online on Language does not mark them online on Mentorship. The Durable Object article in this series covers what that object does.
The programme is decided at the Worker boundary
We resolve the programme and run the request checks before calling React Router, then add the response headers; figure 2 shows the full order. In production we take the programme from the hostname, and the step fails closed. A Worker that has no valid programme scope does not serve at all, and a request whose programme we cannot resolve gets a 404, not a guess:
Adapted for publication.
let requestProgramme: 'language' | 'mentorship';
try {
requireWorkerProgrammeScope(runtimeEnv);
requestProgramme = resolveProgramme(request, runtimeEnv).key;
} catch {
logger.warn('request.programme_scope_rejected', { method: request.method, path: logPath });
return completeResponse(new Response('Not Found', { status: 404 }));
}
Once we know the programme, we build a new context for the request’s middleware, loaders and actions. With React Router 8’s middleware, which we enabled on 31 July 2026 and which became the default when we moved to 8.3.0 the next day, context is a RouterContextProvider, not a plain object:
Verbatim excerpt.
const requestHandler = createRequestHandler(
() => import('virtual:react-router/server-build'),
import.meta.env.MODE,
);
function buildRouterContext(
env: Env,
ctx: ExecutionContext,
logger: Logger,
cspNonce: string,
programme: 'language' | 'mentorship' | 'stored-row',
requestCountry: string | null = null,
): RouterContextProvider {
const rrContext = new RouterContextProvider();
rrContext.set(cloudflareContext, { env, ctx });
rrContext.set(loggerContext, logger);
rrContext.set(securityContext, { cspNonce });
rrContext.set(programmeContext, programme);
rrContext.set(requestGeoContext, { country: requestCountry });
return rrContext;
}
We keep the context keys in one small module that imports nothing at runtime except react-router. If it imported application code at runtime, it could bring a cycle into the Worker entry’s module graph and cause a module-initialisation failure at deploy time; as a leaf, it cannot start that cycle. We import the application types the contexts carry with import type, to keep their contracts without introducing runtime dependencies. The Worker and the application share the exported keys, and each request’s values live in the new provider we create for that request. The provider indexes values by the context object itself, so a second createContext call with the same TypeScript type would create a different key.
We keep three things apart: the Worker that serves a hostname, the runtime programme identity a request runs under, and the product surface a person sees. Skills Academy is its own programme on the same platform; in the code described here, its hostname is served by the Mentorship Worker, uses the Mentorship runtime identity and shows a Skills surface with its own name and words. A hostname of its own does not make Skills a third runtime identity: every request runs as Language or Mentorship. Skills does appear as a programme on stored group sessions, a separate value from the programme of the request that reads them. For the Language and Mentorship runtime identities, we define what each offers in one descriptor: a taxonomy, its session types, and capabilities from one shared vocabulary, such as direct-booking, group-sessions, self-service-signup and credentials. We use the same capabilities to decide whether the interface shows a button and, in a server guard, whether the operation may run. Only the second is a boundary; in the words of our documentation, “UI visibility is not the security boundary.”
Both production builds read one explicit route table, not routes discovered from files. The same path can mean different things on the two hostnames, and we decide in the server guard whether a route does anything for this programme.
The programme is set in context at the boundary, but it is not resolved only once. The root loader, the portal shell and the code that tags an error report with its programme resolve it again from the same request, and the last of those taught us the lesson in the next section.
Scheduled work uses the programme on the row
A scheduled job has no visitor and no meaningful hostname, so it cannot ask the request which programme it belongs to. Our platform rule says it should not try: “Background work must use the programme stored on the booking, session, delivery or account row. An internal cron hostname must never decide a persisted record’s programme.”
For the cron routes we dispatch directly through React Router, we set programmeContext to 'stored-row', a value that cannot be mistaken for Language or Mentorship. Its type, ProgrammeKey | 'stored-row', means we must narrow it before passing it as a programme, and the job code must still take that programme from the stored record.
Where a job runs says nothing about its programme either. Only the Language Worker has cron triggers, so we schedule some Mentorship work there, including the Skills Academy jobs, and send it through a service binding to the Mentorship Worker, as the parent article describes. That decides where the code runs, not which programme a record belongs to.
Our incident notes from 23 July 2026 show what happens when a scheduled path asks the request anyway. A scheduled reminder job failed, and we then tried to tag the failure with a programme for the error report. The scheduled request carries no product hostname, so the strict resolver threw a second time inside the error handler, and the report that reached us named a programme error instead of what had actually broken. We now resolve the programme for the tag in a try and give up quietly: a tag must never decide whether a request can be handled, and the strict resolution that governs access still runs where access is decided.
Middleware that only fills a cache
Our portal shell is the layout around most signed-in pages. Before 31 July 2026, the shell loader and each child loader looked up the session for themselves, and the root loader did too, to choose the language of the page. That day we moved the lookup into the shell’s middleware, which resolves the session once per server request and puts it in sessionContext:
Adapted for publication.
// Resolve the session ONCE per server request for the whole matched branch.
// The middleware only CACHES (sets sessionContext); every redirect decision
// stays in the loaders and guards that own it, and guards without a context
// argument keep resolving for themselves.
export const middleware: Route.MiddlewareFunction[] = [
async ({ request, context }) => {
const env = getRuntimeEnv(context);
const session = await resolveSession(request, env);
context.set(sessionContext, session ?? null);
},
];
We distinguish three states: unresolved, resolved with no session, and resolved with a session. That is why the session context has a symbol default:
Adapted for publication.
/** The session resolved ONCE by the portal shell's middleware. Three states:
* SESSION_UNRESOLVED (the DEFAULT: no middleware resolved it, e.g. a resource route
* outside the shell, so guards fall back to their own lookup), `null` (middleware
* ran and there IS no session), or the session. */
export const SESSION_UNRESOLVED: unique symbol = Symbol('session-unresolved');
export const sessionContext = createContext<Session | null | typeof SESSION_UNRESOLVED>(
SESSION_UNRESOLVED,
);
With createContext, an explicit undefined is no default in React Router 8.4.0: reading an unset context with context.get throws. The first version of our cache test failed on exactly that, on every route the middleware did not cover. When the sign-in guard reads a cached null, we redirect to sign in without another lookup, and only the unresolved marker sends the guard through its original lookup. Our regression test preloads a session and requires the guard to return that exact object; an untouched provider and the older shape of test context must still take the original path.
We counted session lookups on three signed-in Language pages: the learner home, the list of volunteers and the bookings page. Our notes from 31 July 2026 record three lookups per navigation before the change, from the root loader for the page’s language, the shell’s guard and the child page’s guard, and one after it, from the middleware. The count found the last holdout: the root loader, which was not on our list to convert. We have not measured the change against production since, and we have not measured how much time it saves per navigation.
The decision we would make again is that the middleware only fills a cache. Redirect decisions stay in the loaders and guards that own them, and a guard that never receives context still behaves as before. We introduced the typed context and the session cache as separate changes on 31 July 2026, and we can remove the middleware without moving any redirect decision.
Server middleware runs “for HTML Document requests and .data requests for subsequent navigations and fetcher calls”, says the middleware documentation. When a document POST continues from action to loaders, React Router 8.4.0 builds a new Request but passes the same context. We create that context per server request at the Worker entry, so a cached session never crosses requests, and a navigation without a server request has nothing to fill.
What one codebase costs
Our platform documentation states the price as plainly as the rule: “The two Workers are built separately from the same tree. A change in shared code can therefore affect both products even when only one product motivated the change.”
Client catalogue size. We offer six interface languages on Language and four on Mentorship, and the translation catalogues travel with the client code. On 15 October 2026 at 11:18 UTC, the Language sign-in page referred to 50 JavaScript files. We fetched each file on its own with curl: 2,024,340 bytes uncompressed in total, and 509,814 bytes downloaded when we asked for gzip. The largest was the English catalogue, 859,245 bytes uncompressed and 236,980 with gzip. Those are the files the HTML refers to, fetched one by one, not a recording of what a browser transferred before it could hydrate. Our build budgets, listed in the appendix, count what the build ships, uncompressed, not what the first page loads.
We kept splitRouteModules off. In React Router 8.3.0 the transform that splits route modules lost source maps, which could have pointed error reports at the wrong source line. Our configuration notes record a clean production A/B build on 2 August 2026 that showed what we gave up: the client catalogue would have gone from 4,591.0 KB to 4,582.6 KB, and the entry module and the largest lazy chunk did not change. We still load route chunks lazily.
When this is the wrong pattern. If two products share a login screen and little else, two codebases are simpler. Even when they share a data model, we can choose when to deploy each Worker, but a shared change still has to be checked against both products. And if you cannot afford a build per target, the build-time choice of environment will cost you: every target is a full build, and a build for staging cannot become production.
Streaming this app under a strict Content Security Policy has an article of its own.
| Article | What it covers |
|---|---|
| How We Share One Session Lookup Across React Router Loaders | We found a session lookup in the language helper, outside our list of guards. We moved resolution into layout middleware and gave the context three states. |
| One Nonce, Two Inputs: Fixing React Router Streaming | We restored loader data with one nonce input. For late Suspense content, we needed the same nonce in another place. |
| Typed Pings and Failure as Data in React Router | We refresh one unread count without rerunning the page's loaders. To keep the page after a rejected background panel load, we return failure as data. |
| Why We Broke a React Router Build With an Unused Export | We can fail a React Router build on code we would never send to the browser: we check imports before we drop unused code. How we split our files. |
| Old Tabs, New Workers: A Guarded Reload | We traced a crash to old code reading new loader data, then found our tests expecting a failure report from a reload that worked. |
Verify this
- The same reported release on three hostnames. Send
curl -sItohttps://language.theeducationalequalityinstitute.org/login, to the same path on thementorship.andskills.hosts. Each answer carriesx-lc-release, the release header we set on every response the Worker secures: the short commit the build came from, with-dirtyadded when build-relevant files differed from that commit. On 15 October 2026 at 11:18 UTC all three carried the same value, from two separately built Workers. A matching header shows the same reported commit, not the same binary artefact: each Worker is its own build. - The undefined default, in your own project. In a React Router 8 project, compare
createContext<symbol | undefined>(undefined)withcreateContext(Symbol('unset')). Read each withcontext.getbefore setting either: the first throws; the second returns its symbol. - Internal only. The session lookup count, the 23 July incident and the build budgets come from our own repository and notes. You cannot check them from outside; we report them as we recorded them.
Appendix: configuration
| Item | Value |
|---|---|
react-router, @react-router/dev, @react-router/cloudflare | 8.4.0 |
react, react-dom | 19.3.0 |
vite | 8.1.4 |
wrangler | 4.144.0 |
| Router config | ssr: true, serverModuleFormat: 'esm', prerender: false, splitRouteModules: false, lazy route discovery |
| Production Workers built from the tree | 2 (Language; Mentorship, which also serves Skills Academy) |
| Durable Object binding | LIVE_PRESENCE, class LivePresence, migration tag v1, one namespace per Worker |
| Cron triggers | 3 on the Language Worker, none on the Mentorship Worker |
| Client JavaScript ceiling | 12,100,000 bytes, client JavaScript files in the asset folder, manifest excepted |
| Entry module ceiling | 260,000 bytes |
Frequently asked questions
Can one React Router 8 app be deployed as two Cloudflare Workers?
Yes. We build the same source tree twice, once per Worker, and choose the Worker configuration at build time through the Cloudflare Vite plugin. Each build writes to its own output directory, and each Worker is deployed separately.
Why choose the Cloudflare environment at build time and not at deploy time?
With the Cloudflare Vite plugin, the build writes an output configuration that is flattened to the active environment. Cloudflare documents that setting CLOUDFLARE_ENV when running wrangler deploy has no effect, so the build is the only place the choice can be made.
How do loaders know which programme a request belongs to?
In the Worker we resolve the programme from the request before React Router runs and set it on a typed context with createContext and RouterContextProvider. A request whose programme cannot be resolved gets a 404 instead of a guessed programme. For cron routes we dispatch directly, we set a separate stored-row value, and the job code must take the programme from the stored record.
Why give a React Router context a symbol default instead of undefined?
React Router returns a context default only when it is not undefined, so a context created with undefined has no default and context.get throws when nothing set it. We give the session context a symbol default, so a route outside the middleware reads "unresolved" instead of throwing, and we keep null for a session that was looked up and is absent.