In April 2026 I opened a pull request that removed router.refresh() from 57 components in Shelter Sense. Not refactored, removed. Every one of those calls was a small bet that Next.js would reload the page cleanly after a mutation, and on Next 16 that bet kept losing. Forms stuck in a pending state forever. A flood of RSC requests after every save. I remember writing that commit message and thinking "this feels like the wrong direction," but at the time I could not have told you what the right direction was.
Three months later Shelter Sense was running on RedwoodSDK on Cloudflare Workers, and the Next.js sources were deleted from the repo entirely. This is the story of how that happened: what our Next setup looked like, what finally wore us down, why I picked RedwoodSDK instead of something like Waku, and what the move actually fixed (versus what it just traded for different problems).
If you want the wider platform story (Auth, Secret Keeper, Jobs, the Nx libraries), that lives in The Evolution of the Wilkins Software Platform. This one is just the framework swap.
01Where we started: Next.js inside Nest
Here is the part that makes our story a little unusual: our Next.js apps never ran as a standalone next start. NestJS owned the process. Every request hit Nest first. API routes went to Nest controllers, static assets went through Nest's static middleware, and everything else fell through to a shared library that held a Next server instance and asked it to render.
Browser
→ NestJS (single Node process)
├─ /api/* → Nest controllers
├─ /_next/static/* → Nest static middleware
├─ /_next/image → Next image optimizer (with custom patches)
└─ other routes → NestjsServeNextAppService.render() → Next
There were good reasons for this. Nest was the system of record: Prisma, auth, caching, background jobs, all of it. With everything in one process, server actions could talk to Nest services directly, cookies and sessions just worked, and both Shelter Sense and You Power Project ran on the exact same shell.
The catch is that when you host a framework in a way its authors never intended, you become the maintainer of all the weird edges. Image optimization was my favorite example. In a normal deploy, Next's router-server intercepts /_next/image before anything else. In our setup that router-server did not exist, so image requests either 404ed or blew up with "Invariant missing routerServerHandler" somewhere deep in Next's internals. I patched around it by injecting the handler myself, then had to patch my patch two days later because the first version had a race condition under load. When you find yourself monkey-patching Date.now handling inside a framework's server, some part of you knows this is not a stable place to live.
One process also meant one blast radius. A careless import in either direction (Next guts leaking into a Nest-only path, or the reverse) would take down boot with an AsyncLocalStorage error that had nothing to do with whatever feature you were actually shipping. It kept happening. We had lint rules and conventions to prevent it, and it kept happening anyway.
Meanwhile Shelter Sense itself was all-in on Next 16: App Router, server actions, PPR and Suspense streaming, cache tags, a service worker for offline. And the product had one requirement that would end up mattering more than everything else combined: a live walk list, where staff on a shelter floor sign animals in and out and everyone sees the same status in real time. On Next, "real time" meant SSE plus client refresh. Hold that thought.
02What wore us down
There was no single bug that forced the migration. It was attrition, and nearly all of it came down to one question: what happens after a mutation succeeds?
The router.refresh() purge in April was the first big correction. The replacement was cache tags inside server actions, so the response could tell the client what to invalidate without reloading the whole tree. It worked. It also meant our entire mutation model was now a workaround for the framework's own refresh mechanism, which is a strange thing to realize you have built.
That same month, logout broke twice in one day. I put redirect('/login') in the logout server action, which is exactly what the docs suggest. Next implements redirect by throwing a special control-flow error, and our client error boundary dutifully caught it and showed the user "Something went wrong." So logging out worked, but it looked like a crash. I switched to a client-side window.location.assign, which fixed logout, and then had to teach the error boundary to re-throw Next's control-flow errors so redirects could work anywhere at all. A full day of churn for "sign out and go to the login page."
By May the house rule was written down: never call router.refresh() after a mutation, because on Next 16 it could wedge useActionState and useTransition into a permanently pending state even when the mutation had already succeeded (vercel/next.js#86055). Every feature paid a tax for that rule. Create a record inline? Thread it back through callbacks into parent state, because you cannot refresh. Two users sign out the same dog at once? Build a custom resync payload the client can apply locally, because you cannot refresh. Piece by piece, we were building a parallel data-flow system whose entire purpose was to avoid asking Next to reload its own page.
Smaller cuts piled on top. React 19's PPR could briefly leave duplicate copies of an animal card in the DOM, so even our tests had to learn which twin was the real one. Server-action ids are opaque and rotate on every deploy, which meant offline mutation replay could never go through server actions at all (we had to build a stable Nest API for that long before Redwood entered the picture).
I want to be fair here: none of this proves Next.js is bad. Plenty of teams ship great products on it. What it proves is that a custom-hosted, server-action-heavy app with real-time and offline requirements hits a lot of the framework's sharp edges at once. The commits from that spring never say "we should migrate." What they show is months of routing around the framework instead of building with it. At some point you notice the pattern.
03Why RedwoodSDK, and not something else
By June I had stopped asking "how do I work around this" and started asking "what do I actually want." Written from the scars, the list was short. Keep RSC and server actions, because the component model was never the problem; we had hundreds of components built on RSC loaders and thin actions, and they were the good part. Lose the framework's cache and refresh machinery, because Nest and Redis already owned caching for real data and I was tired of the UI framework fighting them for the job. Get a real answer for live UI, because SSE-plus-refresh was the worst seam in the product. And run the next thing the way its authors run it, because I was done hosting frameworks in creative ways.
That list eliminated most of the field fast. Staying on Next but running it standalone would fix the custom-server patches while keeping every mutation problem we had just spent months routing around. Remix and React Router meant giving up RSC and rewriting the data layer of every page. TanStack Start was interesting, but its RSC story was not where we needed it to be.
Waku was the closest call. It is a genuinely minimal RSC framework, and after Next, minimal sounded wonderful. But Waku is deliberately thin and deploy-agnostic: it gives you RSC and routing and gets out of the way. Our worst problems were not routing problems. They were real-time state and the seam between the UI and the backend, and Waku has no opinion about either. I would have been solving those from scratch again, just on a smaller framework.
RedwoodSDK had opinions, and they happened to be the right ones for us. It is built natively for Cloudflare Workers, and that turned out to be the whole ballgame, for two reasons.
The first is Durable Objects. rwsdk's useSyncedState rides on a Durable Object per room: one WebSocket, per-key state, cross-tab and cross-user consistency handled by the platform. That is exactly the shape of the walk list. Staff sign a dog out, every phone and tablet on the floor updates. On Next we were faking this with SSE and page reloads. On Workers it is a primitive you just pick up.
The second is that the Worker runtime makes the boundaries physical. A Worker cannot import Prisma or Nest internals; the runtime simply will not run it. The boundary we had spent years enforcing with lint rules and hope became something the platform enforces for free. Every data access in the UI goes through one bridge call:
// Worker / server action: name a Nest handler, pass a payload
const pageData = await callAppService('getUserManagementPageData', {
orgSlug,
});
And RedwoodSDK kept the things we liked. RSC, server actions as plain functions, Vite instead of a bespoke bundler. It also pointedly does not ship a 'use cache' or PPR equivalent, which most reviewers list as a gap. For us it was the selling point. We already had a cache. We wanted a framework that did not come with a second one.
Was betting a production shelter product on a young framework risky? Absolutely. Which is why the migration went the way it did.
04The migration: parallel, bridged, then split
I did not rewrite in place. In late June the monorepo grew shelter-sense-v2: a complete parallel RedwoodSDK app sitting next to the Next one, with Playwright wired so the same e2e suite could run against either. The Next app kept shipping while v2 chased parity, and the tests were the referee. If v2 could not pass the same suite, it was not ready. Simple as that.
The pace surprised even me:
| When | What landed |
|---|---|
| 2026-06-30 | Scaffold shelter-sense-v2 beside the Next app |
| 2026-07-02 | The callAppService bridge; server-action bodies move into Nest handlers |
| 2026-07-04 | Nest/Redis read-cache replaces Next cache tags |
| 2026-07-06 | Delete the Next sources; v2 becomes shelter-sense |
| 2026-07-08 | Split: Cloudflare Worker serves the browser, Nest goes API-only on Fly |
| 2026-07-09 | useSyncedState live UI replaces SSE-plus-refresh |
| 2026-07-20 | The marketing site follows, as a worker-only RedwoodSDK app with no backend at all |
The move that made everything else possible was the bridge, and it landed first. The July 2 commits moved the bodies of our server actions into Nest handler methods and taught the UI to reach them through callAppService, with cookies forwarded so Nest resolves the same session. That one change is the whole migration in miniature: Nest becomes the stable system of record, and the UI becomes a replaceable shell. Once that boundary existed, swapping the shell was mechanical instead of terrifying.
Cutover happened in two hops rather than one leap. The first Redwood phase still had Nest hosting the worker runtime, so nothing changed for anyone using the app while the UI changed completely underneath them. Only after that was stable did I split the topology: browser traffic goes to a Cloudflare Worker, Nest runs API-only on Fly, and the worker proxies /api/* to Nest so same-origin cookies survive.
Before After (July 8 onward)
Browser → Nest (one Node process) Browser → Cloudflare Worker (rwsdk UI,
├─ API + Prisma SyncedState DO, crons/queues)
└─ UI (Next) ├─ /api/* proxy → Nest on Fly
└─ callAppService → Nest
└─ Prisma / Redis
And on July 9, the payoff commit: the SSE providers and the post-mutation refresh helpers were deleted in the same change that introduced useSyncedState. Sign a dog out, the client sets the live value, the Durable Object fans it out to every other tab and user on the floor. No refresh. No reload. No wedge.
It was not all smooth, and the bumps are worth sharing. rwsdk treats capnweb as an optional peer dependency, and our install had skipped it, so SyncedState was completely dead while I debugged everything except the dependency tree. That one stung. Another: rwsdk renders the HTML document and the app as two separate React trees, so a context provider placed in the document is never an ancestor of your pages. Put a navigation provider in the wrong tree and useSearchParams throws, a Suspense fallback hangs forever, and it looks exactly like a routing bug. It is not a routing bug. I know this now. The mechanics behind it turned out to be interesting enough that I wrote them up separately: RedwoodSDK's Document Is Not Your App's Parent.
You Power Project adopted the same split shortly after. The marketing site (the one you are reading this on) went further and dropped the backend entirely: worker-only MDX on Cloudflare, no Nest, no database. Same toolkit across the whole platform, sized to what each app actually needs.
05What it solved, what it cost
Three months in, here is where things landed.
The post-mutation refresh problem is gone. Not worked around, gone. SyncedState plus a client-side set after a successful write is how the framework wants you to do live UI, so the resync payloads and the "never refresh" house rule and all the callback-threading are no longer load-bearing. Caching has one owner now (Nest and Redis), because rwsdk does not ship a competing cache to fight with. The worker deploys the way rwsdk expects, so the /_next/image patch archaeology is deleted. And the imports that used to take down boot are now physically impossible: the Worker cannot load Prisma even if someone tries.
The costs are real too, and I would rather you hear them from me than discover them yourself:
- Two deploys, two Sentry projects, two places for secrets. And a stricter env model:
VITE_PUBLIC_*values are baked in at build time, so setting them as runtime secrets on an already-built bundle does exactly nothing. That one bites everybody exactly once. - A hosting invoice. Free-tier Workers give you roughly 10 ms of CPU per request, and real SSR blows through that instantly. Production rwsdk SSR needs the paid Workers plan.
- Edge caching does not forgive. Immutable cache headers plus a cacheable 404 equals a poisoned deploy that browsers remember for a year. Our deploy pipeline now retains previous builds' hashed assets and forces every non-2xx response to
no-store. The deploy docs literally say "learned the hard way," because we did. - Durable Objects can drift from the database. The DO is fast shared state, not the source of truth, and the publish path into it can fail quietly. We run a reconcile job rather than pretending that cannot happen. Better live UI, one new thing to operate.
RedwoodSDK did not make Shelter Sense simple. It moved the hard parts to seams I was willing to own.
That sentence is the honest summary. On Next, the hard parts lived inside the framework's cache, refresh, and custom-server internals, where my only tool was the workaround. On RedwoodSDK, the hard parts live at boundaries I chose: the bridge to Nest, edge caching, Durable Object operations. They are still hard. But they are my seams, they fail in ways I can inspect, and the framework is not on the other side of them fighting back.
Key Takeaways
No single bug forced the migration. The signal was months of routing around Next 16's mutation and refresh model (
router.refresh()wedges, swallowed redirects, cache-tag workarounds) instead of building with it.Hosting Next inside Nest made us maintainers of framework internals we never wanted to own, from the image optimizer to AsyncLocalStorage boot failures.
RedwoodSDK beat alternatives like Waku because it kept RSC and server actions while being Workers-native: Durable Objects gave the live walk list a platform primitive, and the Worker runtime made the UI/backend boundary physically enforceable.
The migration was parallel-first and bridge-first: build v2 beside v1, move action bodies behind
callAppServiceso Nest stayed the system of record, prove parity with the same e2e suite, then cut over in two hops.The trade was Next's internal seams for seams we own: dual deploys, build-time public env, paid Workers CPU, unforgiving edge caching, and DO-vs-database reconciliation. Worth it for this product; your mileage may vary.
The longer platform arc (Auth, Secret Keeper, Jobs, the monorepo libraries) is in The Evolution of the Wilkins Software Platform. The product story that made Shelter Sense worth this much infrastructure is The Origin of Shelter Sense.




