Engineering noteResearch & Engineering
Part of: React Router 8
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.
The programme
On this page
We made a React Router route build by deleting one word: export. In our reproduction, we called a helper only from the loader, yet still reached its server-only import during the client build. We remove four server exports first, resolve and check the remaining imports by file name next, and discard unused code afterwards. We can therefore fail the build on code we would never send to the browser. Across the thirteen boundary fixes we recorded, we moved shared values into plain modules, isolated server helpers or removed exports nothing in the browser needed. If your type checks and unit tests pass but your build fails, follow the imports your remaining exports reach.
TEEI Language and TEEI Mentorship are two products built from one React Router 8 codebase and deployed as two Cloudflare Workers; Two Workers, One React Router 8 App: Decide at the Boundary describes the application and How TEEI Runs Its Platforms on Cloudflare Workers the platform underneath it. Between 21 July and 14 October 2026, we recorded thirteen fixes for the same error, Server-only module referenced by client. We found these failures through production builds and, in one case, through requests to our development server. Our type checks, lint and unit tests did not detect the boundary error itself. Below we explain why, and how we decide what belongs in the React Router client bundle.
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.
What the framework removes, and what it ships
We compile each route file twice, once for the Worker and once for the browser. The documentation on code splitting says that “any server-only Route Module APIs will be removed from the bundles”. We found the exact list in the installed plugin, @react-router/dev 8.4.0, where a constant names four exports: loader, action, middleware and headers. In the browser build, we then take three steps in a fixed order:
- We remove the four server exports from the route file, together with the code only they use. Before resolving browser imports, we remove bindings used only by the server exports and any import declarations left empty. A bare side-effect import has no binding, so it survives this step.
- We resolve every import that remains and check it by file name. At this point every remaining export of the route file still counts, custom ones included. For a
.serverfile, we get a failed build here. - We discard unused code. From each route’s browser entry, we have re-exported only the exports React Router knows, such as the component,
meta,links,handleandErrorBoundary. A comment in the plugin says why: without that “we can’t tree-shake any unused custom exports”. That entry makes an unused custom export eligible for removal, but we remove it only now, after we have resolved and checked its imports.
That leaves three cases for an import in a route file:
- Used only by the four server exports, or by code only they call. We never resolve it in the browser build, so it is not checked and does not ship.
- Used by the component,
meta,linksor another known client export. We resolve it and include it in the client build. If it is a.serverfile, our build fails. - Used only by a custom export that nothing in the browser uses, such as a helper you export so a test can call it. We still resolve it. If it is a
.serverfile, we get a failed build before anything is dropped. If it is a plain module, its unused values can be dropped, as in our reproduction, where the module had no side effects; a module’s side effects can remain in the browser output.
The third case caught us more than once. In our reproduction on 15 October 2026, a route whose loader called an exported helper that read a server-only module failed the build. The same route with the word export deleted from the helper built cleanly. And when the exported helper read a plain module instead, the build passed, and neither the helper nor the plain module’s string was in the browser output.
Where we reject a client import
The documentation on .server modules says: “The build will fail if any code in a .server file or .server directory accidentally ends up in the client module graph.” In the 8.4.0 plugin we read, the check is a Vite plugin with enforce: 'pre', and we use it to check every client import, including dynamic imports. We get a build error when the resolved file name ends in .server (with any JavaScript or TypeScript extension) or the file sits in a .server directory.
This is the error our reproduction printed on 15 October 2026, for a route component that read a constant from a server-only file. We replaced the route file’s path with a placeholder; the rest is as printed:
Adapted for publication.
Error: Server-only module referenced by client
'../lib/limits.server' imported by route '<route file>'
React Router automatically removes server-code from these exports:
`loader`, `action`, `middleware`, `headers`
But other route exports in '<route file>' depend on '../lib/limits.server'.
See https://reactrouter.com/explanation/code-splitting#removal-of-server-code
When the importer is not a route file, the message is shorter: the first line and which file imported the module. We need to account for three properties of the check:
- We are checked in the client build only. We never get a
.servererror from our Worker build, which is correct. - In development we meet the check when a client module is transformed. Vite’s dependency scan does not run it, but a page request can reach it before the browser asks for the module. On 30 July 2026, on our development server, we got a 500 instead of the 403 we expected when a session without the staff role requested a page our staff use. We reproduced it in three independent runs. The page’s reply box read its length limit from a server-only module; with the constant moved, we got 403 for the same request and no trace of the error in the server log.
- We do not build the client module graph in type checks, lint or unit tests. In the words of the commit that fixed our 15 August failure: “React Router’s client/server boundary is per file, not per export”, and “typecheck, eslint and vitest cannot see this class of bug”.
On 15 August, a link builder used by a route component shared a module with ranking code that had a server-only import. The component never called the ranking code. We still had to resolve the module’s imports before we could discard its unused exports, and we remove the four server exports only from route modules, so importing one name from an ordinary module gets no earlier removal. We reproduced this with a three-line module.
What we still have to keep out of the client
We are checked on file names, not on what a module contains. A secret in an ordinary module that a component uses ships to the browser, and we still get a passing build. In our reproduction, a string in a plain module rendered by a component was in the client output, and react-router build exited with 0. We therefore keep anything that must never reach a browser, such as a database client, a key or a query, in a .server file, so that we get a build failure when an import of it accidentally remains in the client module graph.
Thirteen fixes, three shapes
We found our thirteen fixes by searching commit messages and code comments; there may be more that used other words. In each case, a value the client used or an extra route export left code in the client graph that reached a .server module, directly or through another file. We kept the check every time and changed the code in one of three ways.
We moved a shared value into a plain file. On 21 September 2026 a form that lists five session formats read the five strings and a type guard from a server module. We moved them into a plain file that both sides import. Our main line did not build for several hours that day, and one of us found it while running a release check for unrelated work. We treat a change at this boundary as verified only once react-router build has passed on it; each release build of a Worker runs it first.
We moved server helpers into a server-only module of their own. On 24 September 2026 a route exported its batching helpers beside its action. We moved the helpers into a server-only module, and after the fix the route exported only action.
We removed an export. On 14 October 2026 an API route with no component exported its count function, and that function reached a server-only module. We still compile such a route as a route module for the browser, so the check applied. We removed the export, as we had for two impact endpoints that exported their compute functions on 2 October. On 28 September a statistics route exported, for a test, a query whose text was built with server-only helpers; there we moved the query into a server-only module instead.
The split that fixes it
When a value belongs on both sides, we put it in a plain module that both can import. A compact example comes from 3 August 2026. A staff page rendered a ratio only when enough data stood behind it. The function that computed the ratio and the minimum sample size lived in the same server module as the queries that produced the numbers. They were pure arithmetic over two integers, with no database and no environment, and the component was right to need them. The commit put it this way: “a value a rendered component needs is not server code, however closely it sits to some.”
Adapted for publication.
// metrics.ts: no server imports, safe for both builds
export interface Ratio { numerator: number; denominator: number }
export const MIN_SAMPLE_FOR_RATE = 5;
export function rate(r: Ratio, minSample = MIN_SAMPLE_FOR_RATE): number | null {
if (r.denominator < minSample || r.denominator === 0) return null;
return r.numerator / r.denominator;
}
Adapted for publication.
// metrics.server.ts: the queries stay here
import { createContext } from 'react-router';
import { MIN_SAMPLE_FOR_RATE, type Ratio } from './metrics'; // needed because this module uses the names itself
export { MIN_SAMPLE_FOR_RATE, rate, type Ratio } from './metrics'; // so existing server imports keep working
export interface Database {
countCompleted(): Promise<Ratio>;
}
export const databaseContext = createContext<Database>();
export async function measureCompletion(db: Database): Promise<Ratio | null> {
const r = await db.countCompleted();
return r.denominator < MIN_SAMPLE_FOR_RATE ? null : r;
}
Adapted for publication.
// routes/staff.metrics.tsx
import { databaseContext, measureCompletion } from '~/lib/metrics.server'; // used by the loader only: removed from the client build
import { rate } from '~/lib/metrics'; // used by the component; we include it in the client build
export async function loader({ context }: Route.LoaderArgs) {
const database = context.get(databaseContext);
return { completion: await measureCompletion(database) };
}
export default function Metrics({ loaderData }: Route.ComponentProps) {
return <Measure value={loaderData.completion && rate(loaderData.completion)} />;
}
In this shortened example, the database exposes one query, countCompleted(), and Measure stands for any component that displays the value. Before calling React Router, the Worker stores that database with routerContext.set(databaseContext, database), using the same exported context key. The loader reads the database from the provider; the provider itself is not a database.
We keep the import and the re-export separate. With export { x } from './metrics', we re-export x without binding it inside the server module. On 21 September 2026 we got a ReferenceError in tests after using a re-export alone, and we fixed it by adding the import. The component still imports the plain module directly.
When this is the wrong pattern
We use the loader when the component needs server-owned data. A count or a setting read for the current request comes through the loader; we keep its query and server helper on the server. We add a plain sibling when both builds need the same code, such as the ratio formula and its minimum sample size. That split costs another file, even when it holds only five strings and a type guard, as ours did on 21 September 2026.
The order is the plugin’s, at one version. We found the four exports in the plugin code rather than the documentation, and read the order of the three steps in @react-router/dev 8.4.0. Check the version you have installed before you rely on it.
Verify this
You can repeat the export experiment in your own project. We ran these steps on 15 October 2026 in a small hand-made project with React Router 8.4.0, @react-router/dev 8.4.0 and Vite 8.1.4, using React Router’s default Node server build rather than the Cloudflare plugin. We ran them again on 21 October 2026 from the files below, with the same versions and the same results.
We start from these files rather than a generated template: create-react-router downloads its starter from the main branch of React Router’s template repository, so a project made with it later may not match the one we tested.
Example for your own project.
mkdir -p r5-repro/app/routes r5-repro/app/lib && cd r5-repro
cat > package.json <<'EOF'
{
"name": "r5-repro",
"private": true,
"type": "module",
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0",
"@react-router/node": "8.4.0",
"isbot": "5.1.44"
},
"devDependencies": {
"@react-router/dev": "8.4.0",
"vite": "8.1.4"
}
}
EOF
cat > react-router.config.ts <<'EOF'
export default { ssr: true };
EOF
cat > vite.config.ts <<'EOF'
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [reactRouter()] });
EOF
cd app
cat > root.tsx <<'EOF'
import type { ReactNode } from 'react';
import { Links, Meta, Outlet, Scripts } from 'react-router';
export function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head><Meta /><Links /></head>
<body>{children}<Scripts /></body>
</html>
);
}
export default function App() {
return <Outlet />;
}
EOF
cat > routes.ts <<'EOF'
import { index } from '@react-router/dev/routes';
export default [index('routes/home.tsx')];
EOF
cat > routes/home.tsx <<'EOF'
export default function Home() { return <p>home</p>; }
EOF
cd .. && npm install
- Create the project with the commands above. They pin
react-router,@react-router/devand@react-router/nodeto 8.4.0 and Vite to 8.1.4, the framework and bundler versions we used. - In
app/lib, createlimits.server.tswithexport const REPLY_MAX_LENGTH = 2000;. In the home route,home.tsxinapp/routes, import it at the top and use it in the component:<textarea maxLength={REPLY_MAX_LENGTH} />. Runnpx react-router build. You getServer-only module referenced by clientand the message shown earlier. - Move the constant to
limits.tsinapp/lib, import it from there in the component, and letlimits.server.tsimport it too. Your build now passes. Use this as the baseline for the next two steps. - Put a recognisable string in
limits.server.ts, and add a helper to the route file that reads it and is called by the loader. Withexportin front of the helper, you get a failed build. Without it, you get a passing build, and you will not find the string inbuild/client. - Keep the exported helper, but read the string from an ordinary, side-effect-free module instead. The build passes, and neither the helper nor the string is in
build/client: in step 4 you had a failed build for code that would have been dropped. A module with side effects can leave code in the output.
In the same project, a component that loads limits.server.ts with import() inside a useEffect fails the build. A component that imports only a label from an ordinary module whose other function imports limits.server.ts fails it too. We can render a string from an ordinary module, get a passing build and find the string in build/client. The dates of our thirteen fixes come from our repository, and you cannot check them from outside.
If you build with React Router on Cloudflare Workers and want to compare notes, talk to us.
Appendix: configuration
| Setting | Value |
|---|---|
React Router, @react-router/dev | 8.4.0 |
| Vite | 8.1.4, with Rolldown 1.1.5 as the bundler |
@cloudflare/vite-plugin | 1.62.2 |
| Wrangler | 4.144.0 |
ssr | true |
| Reproduction | React Router’s default Node server build, with the same React Router and Vite versions and no Cloudflare plugin |
Frequently asked questions
Why does react-router build fail with “Server-only module referenced by client” when the type check passes?
We get the error from a Vite plugin in React Router through which we check every import in the client build by file name. If a file whose name ends in .server, or a file in a .server directory, is still reachable after the four server exports are removed, we get a failed build. Our type checks, lint and unit tests do not build the client module graph, so we meet it only in a build or when we transform a client module in development.
Can an unused export fail a React Router 8 build?
It did in our reproduction on 15 October 2026. A route helper called only by the loader failed the client build while it was exported and read a .server file, and built once we deleted the word export. We resolve and check imports before unused code is dropped, so at that point an exported helper still counts.
Does naming a file .server keep secrets out of the browser?
Not on its own. We use .server names to fail the build when an import of such a file remains in the client module graph. In our reproduction on 15 October 2026, we rendered a string from an ordinary module, got a passing build and found the string in the client output.