Remix v3 Hits RC: Upgrading Five Apps the Morning After

Remix published 3.0.0-rc.1 on 31 August 2026. I run five apps on Remix v3 — this site among them — and all five were sitting on 3.0.0-beta.5, so the next morning I upgraded everything in one sitting. The version bump itself was the least interesting part. The upgrade tripped two wires I did not expect, and confirmed one suspicion I already had about my own test suites.

Five apps, one scaffold

All five projects came out of remix new and kept the same shape: a plain node:http server in server.ts, a router in app/router.ts, and a server-side asset pipeline in app/assets.ts. No bundler, no framework CLI — the dev script is just Node with a loader:

"dev": "NODE_ENV=development node --watch --import remix/node-tsx server.ts"

That sameness matters later. When five repos share a scaffold byte-for-byte, an upgrade is one diagnosis and five applications of the same patch — and one breaking change is five broken apps at once.

Wire one: npm said the version didn't exist

The first npm install remix@3.0.0-rc.1 failed with an error that reads like a typo on my part:

npm error notarget No matching version found for remix@3.0.0-rc.1
npm error notarget with a date before 8/25/2026, 10:03:22 PM.

The version existed. The clue is the date. My ~/.npmrc carries a supply-chain guard I set up after the wave of registry-poisoning incidents:

minimum-release-age=10080

Ten thousand and eighty minutes is seven days. npm quietly translates that into a --before cutoff, so any package published in the last week is invisible — which is exactly what you want when a maintainer's token gets phished and a malicious patch release goes up, and exactly what you do not want the morning after an RC you have been waiting for.

The right move is not to delete the guard, it is to make a deliberate exception for the one package you have decided to trust today:

npm install remix@3.0.0-rc.1 --minimum-release-age=0 --before=

The flag overrides the config for that single command; the guard stays in place for everything else, including the transitive dependencies of tomorrow's installs. If you run a release-age guard — and you should — remember that its failure mode looks like a missing version, not like a policy refusal. Read the date in the error before you start doubting the registry.

Wire two: the asset server API changed shape

With rc.1 installed, all five servers crashed on boot with the same stack:

TypeError: Cannot read properties of undefined (reading 'map')
    at access.js:18 (in the @remix-run assets package)

Beta-to-RC is exactly where a framework should spend its last breaking changes, and createAssetServer spent them on its two least self-explanatory options. The beta.5 scaffold configured the asset pipeline like this:

export const assetServer = createAssetServer({
  basePath: '/assets',
  rootDir,
  fileMap: {
    'app/*path': 'app/*path',
    'node_modules/*path': 'node_modules/*path',
  },
  allow: ['app/assets/**', 'node_modules/**'],
  // ...
})

In rc.1, fileMap became mounts — a plain record of public URL path to directory, with a sensible default of { app: 'app', npm: 'node_modules' } — and allow became allowFiles, now accompanied by allowPackages for granting whole packages by name and denyFiles for carve-outs. The migrated config:

export const assetServer = createAssetServer({
  basePath: '/assets',
  rootDir,
  mounts: {
    app: 'app',
    node_modules: 'node_modules',
  },
  allowFiles: ['app/assets/**', 'node_modules/**'],
  // ...
})

I kept the explicit node_modules mount rather than adopting the new npm default, because my HTML already ships URLs under /assets/node_modules/… and I would rather not chase down cached documents pointing at moved assets during an RC upgrade.

Credit where due: the rename is better in every direction. fileMap with *path patterns on both sides was ceremony that almost always mapped a directory to itself; mounts says what it is. And splitting allow into files versus packages makes the security posture legible — you can now grant allowPackages: ['@fontsource/inter'] instead of a blanket node_modules/** glob, which is on my list for a follow-up pass.

Because all five repos had identical assets.ts files, the actual fix was one perl -0pi substitution run in a loop over five paths. Scaffold monoculture cuts both ways, and this time it cut in my favor.

The suspicion confirmed: five green test suites, zero tests

Here is the part I am publishing so that it embarrasses me into fixing it. After the install, I ran npm test across the repos before smoke-testing anything, and every suite passed:

ℹ tests 0
ℹ pass 0
ℹ fail 0

Zero tests, zero failures, exit code zero. Every one of those apps was completely broken at that moment — none of them could boot — and CI would have shipped all five. A green test run is only evidence if the denominator is nonzero.

The verification that actually caught the breakage was embarrassingly primitive: boot each server, request /, scrape the first /assets/… URL out of the HTML, and request that too.

NODE_ENV=development timeout 10 node --import remix/node-tsx server.ts
# then, while it runs:
curl -s -o /dev/null -w '%{http_code}' http://localhost:44100/
curl -s -o /dev/null -w '%{http_code}' http://localhost:44100/assets/app/<first-entry-from-the-html>.ts

Two requests per app — one proving the router and renderer survive, one proving the asset pipeline compiles and serves — caught what five test suites could not. Until real tests exist, that two-curl smoke check is the floor, and node --test reporting tests 0 deserves to be treated as a failure, not a pass.

The tally

Five apps from 3.0.0-beta.5 to 3.0.0-rc.1: one npm flag to get past my own supply-chain guard, one two-option rename in assets.ts applied five times, and zero changes to routes, controllers, middleware, or rendering. Servers boot, pages render, compiled assets serve, and the production build of this site emitted its 21 pages without complaint. For an upgrade spanning two months of pre-release churn, that is a short bill — and the page you are reading is the deploy that proves it.