I've been writing React for a long time, and I want to tell you a story about a round trip. It starts with me being a true believer in Next.js, runs through the era when the App Router and Server Components moved into my house and rearranged the furniture, passes through a couple of genuinely scary security incidents, and ends with me happily back on a boring, well-understood architecture: a single-page app and a separate backend that talk over a plain HTTP boundary.
This isn't a "framework X is dead" hot take. It's a description of how my own thinking changed, and why the thing I reach for in 2026 looks a lot like the thing I would have reached for in 2018... except smarter about the parts that actually matter.
When Next.js was genuinely great
I want to be fair to Next.js, because for a long stretch it earned its reputation.
The Pages Router era was a sweet spot. You had file-based routing that you could explain to a new hire in five minutes. You had getStaticProps and getServerSideProps - two functions, clearly named, with an obvious mental model: one runs at build time, the other runs per request, and everything else is just React. You got code splitting, image optimization, and a dev server that mostly worked. Deploying was a non-event.
The thing I appreciated most was that the boundary between server and client was legible. Data fetching happened in named lifecycle-ish functions at the top of a route. The component tree below them was ordinary client React. I could point at any line of code and tell you which machine it ran on. That legibility is worth more than people realized at the time, and we mostly only noticed it once it was gone.
So when I say I left, understand that I left something I used to love.
Then the App Router and Server Components moved in
The App Router and React Server Components were pitched as the next evolution, and on paper the pitch is seductive: render components on the server, ship less JavaScript, colocate data fetching with the component that needs it, stream HTML to the browser as it becomes ready. Who doesn't want less JS and faster paint?
In practice, here is what I actually experienced.
The mental model fractured. Suddenly every file was either a Server Component or a Client Component, the distinction was load-bearing, and the boundary was declared with a "use client" string at the top of a file. That directive is viral in one direction and silent about it. A component that was fine yesterday breaks today because something three levels up crossed the boundary, and the error you get is rarely "you crossed a boundary"; it's a serialization complaint, or a hydration mismatch, or a hook being called somewhere it can't be.
"It works in dev" stopped meaning anything. Caching behavior between next dev, next build && next start, and the production edge deployment diverged enough that I stopped trusting local results. I had bugs that only existed in the production cache. I had stale data that no revalidate value seemed to fix. I learned more about the Next.js caching layers than I ever wanted to, and the reward for that knowledge was a permanent low-grade anxiety about whether any given page was actually fresh.
The server/client boundary stopped being legible. This is the one that really got me. The thing I loved most about the Pages era, being able to look at code and know where it ran, was exactly the thing RSC took away. Data fetching is now scattered through the tree. async components look like normal components but aren't. The boundary is real and consequential but invisible at the call site.
None of these are unfixable in isolation. Together they meant I was spending a large fraction of my time fighting the framework's model instead of building the product. That's the signal I should have read sooner.
Then the security incidents made it concrete
Architectural friction is a judgment call. Security incidents are not, and a run of them turned my vague unease into a decision.
It started with CVE-2025-29927, the middleware authorization bypass disclosed in March 2025 at CVSS 9.1. The mechanism was almost embarrassingly simple: Next.js used the internal header x-middleware-subrequest to mark subrequests and prevent infinite middleware loops, but the header was never meant to be client-controlled, and by spoofing it, attackers could skip middleware entirely, auth and authorization included, and reach protected routes with one crafted curl.
If that had been a one-off, I'd have shrugged it off; every framework has bugs. But it wasn't a one-off. The back half of 2025 and the start of 2026 brought a steady drumbeat, and the pattern is what mattered:
-
CVE-2025-55182 (December 2025) - a React Server Components flaw rated CVSS 10.0, the maximum. It sat in the
react-server-dom-*packages and therefore affected every framework built on RSC & Next.js, React Router's RSC APIs, Waku, Parcel's RSC plugin, and the Vite RSC plugin. A perfect score is rare, and this one lived in the RSC machinery itself. - CVE-2026-23864 (January 2026, CVSS 7.5) - a denial-of-service flaw in React Server Components: a malicious payload sent to a Server Function endpoint triggers memory exhaustion or runaway CPU. Patched alongside two more medium-severity Next.js CVEs in the same release.
- CVE-2026-23869 and CVE-2026-23870 (early 2026) - more DoS issues in Server Components, triggered by specially crafted HTTP requests to App Router Server Function endpoints that blow up CPU on deserialization. Affected Next.js 13.x through 16.x on the App Router.
- CVE-2026-44578 (May 2026, CVSS 7.8) - a server-side request forgery in the self-hosted Next.js Node server: crafted WebSocket upgrade requests let an attacker proxy requests to arbitrary internal destinations, including cloud metadata endpoints.
Look at where these clusters are. A middleware bypass, then a string of Server Component and Server Function flaws, several of them triggered specifically on deserialization of a crafted request. This isn't bad luck distributed randomly across a codebase. The vulnerable surface is the server-rendering and server-function machinery, the exact part of the stack that the App Router and RSC made central. The more your framework does on the server on every request, the more attack surface you have signed up for, and a 10.0 in the shared RSC packages means a single bug ripples across every framework that adopted the model.
What unsettled me wasn't any single CVE... it was what the cluster implied about the architecture. Teams like mine had been quietly nudged to treat middleware as the authorization layer, because in the App Router model, auth-in-middleware is the path of least resistance. The level-headed takeaway in every writeup was the one that stung: middleware should supplement, not replace, security enforced closer to the data. So, I sat with a question: how much of my security posture do I want coupled to a rendering framework's internal request plumbing? My answer was "as little as possible."
The TanStack route - also not a free lunch
When developers get fed up with Next.js, a common next stop is the TanStack ecosystem - TanStack Router and TanStack Start. And I'll be honest: TanStack Router is a genuinely nice piece of engineering. The type-safe routing is best-in-class, the search-param handling is thoughtful, and the data loading is well-considered. If you'd asked me purely about developer experience, I'd have said good things.
But "switch to TanStack" is not the escape hatch some people think it is, and recent history makes that point sharply.
In May 2026, the TanStack npm packages were hit by a supply chain attack. On 11 May 2026, a threat group ran a coordinated supply chain attack against the npm and PyPI ecosystems, compromising packages across multiple namespaces, including the @tanstack namespace, which contains @tanstack/react-router, one of the most widely-used routing libraries in the React ecosystem, with roughly 12 million weekly downloads. Between 19:20 and 19:26 UTC on a single Monday, the attacker published 84 malicious versions across 42 TanStack packages.
The payload was not subtle. It targeted CI/CD tokens, cloud credentials across AWS, GCP, and Azure, Kubernetes service accounts, Vault, and registry tokens, and it used stolen npm and GitHub Actions tokens to publish poisoned versions of more packages, functioning as a worm spreading through the npm ecosystem. It also installed a persistent daemon that polled GitHub every 60 seconds, and on detecting token revocation would attempt to run rm -rf on the user's home directory. One detail makes this especially grim: the compromised packages carried valid SLSA Build Level 3 provenance attestations, making it the first documented npm worm to produce validly attested malicious packages because the malicious versions were published through the project's own GitHub Actions release pipeline using hijacked OIDC tokens.
I want to be careful and fair here. This was not a flaw in TanStack Router's code. The attackers got in by forking a TanStack repository on GitHub and submitting a malicious commit under a fabricated identity. The TanStack maintainers responded quickly and communicated well. This could have happened, and has happened, to almost anyone - 2025 saw significant growth in supply chain attacks, and the npm ecosystem, because of its popularity, absorbed a large share of them. The September 2025 Shai-Hulud worm was the first self-propagating worm in the npm ecosystem and affected over 500 packages.
So the lesson I drew from the TanStack incident isn't "TanStack is unsafe." It's the opposite of tribal. The lesson is: no framework choice immunizes you from supply chain risk, and the more of your stack you concentrate into one heavyweight meta-framework, the larger and more attractive a single compromise becomes. Switching framework brand doesn't fix that. Reducing surface area and dependency count does.
The deeper reason full-stack RSC was always going to be hard: serialization
Set the CVEs aside for a moment, because I think there's a more fundamental issue, and it's the one that finally settled my thinking.
The whole promise of Server Components, server actions, and streaming is that the server/client boundary should feel seamless... You just write components and call functions, and the framework figures out what runs where. But a function call across a network is not a function call. The boundary is real, and it is made of serialization.
Everything that crosses from server to client has to be serialized into the RSC payload, streamed, and deserialized. That constraint quietly shapes everything:
- You can't pass a function to a Client Component from a Server Component unless it's a server action, because functions don't serialize. So "just pass a callback", the most ordinary thing in React, becomes a boundary decision.
- Class instances,
Datesemantics,Map/Set, anything with methods or identity, all of it has to be flattened to plain data, or it doesn't make the trip cleanly. - Errors that originate on the server are serialized before you see them on the client, so the stack trace you get is often a translation of the real problem rather than the problem itself.
- Streaming adds time as a dimension. Components resolve out of order, Suspense boundaries flush independently, and now you're reasoning about the partial states of a tree mid-stream, which is a genuinely harder mental model than "fetch, then render."
This isn't a bug anyone can patch. It's intrinsic. The instant you let a UI tree straddle a network boundary, you have signed up for a distributed-systems problem dressed in component clothing, and serialization is where that problem leaks through. RSC's seamlessness is, to a real degree, a leaky abstraction over an inherently unseamless thing. You can build impressive demos on it. Holding it stable across a large app, a team, and a year of changes is a different sport.
And notice the connection back to that CVE cluster. Several of those 2026 flaws, the Server Function DoS bugs, trigger on deserialization of a crafted request. That is not a coincidence. Deserializing untrusted input into a live program is one of the oldest, most dangerous operations in computing, and the RSC model puts a deserialization boundary on the hot path of ordinary feature work. The architecture didn't just make my app harder to reason about; it made the framework itself a richer target. Serialization is both the ergonomic tax and the security surface, the same seam, charged twice.
Once I framed it that way, my decision basically made itself. I don't want my UI framework and my data boundary fused. I want the network boundary to be explicit, visible, and boring, a place I deliberately walk up to, not a seam hidden inside my component tree.
The stack I actually use now
So here's where I landed. None of it is exotic. That's the point.
Frontend: React Router in framework mode, on Vite, mostly SPA
I use React Router in its framework mode with Vite. Framework mode gives me the things I genuinely missed from Next.js file-based-ish routing conventions, loaders and actions, nested layouts, type-safe params without forcing a server-rendering model on me.
The crucial part: I don't run SSR. A handful of pages that are truly static marketing, docs, and the landing page get prerendered at build time, so they ship as fast static HTML and are friendly to crawlers. Everything else is a plain client-rendered SPA.
People will say, "But SPAs are slow/bad for SEO / out of fashion." For an authenticated application, a dashboard, a tool, or a product behind a login, almost none of that critique applies. There's nothing for Google to index behind the login wall. The first paint is a thin shell, the bundle is code-split per route, and from then on, navigation is instant because it's all client-side. I get a build artifact that is just static files. I can put it on any CDN or object store. There is no server-rendering process to keep alive, patch, scale, or get a CVE in. The deployment story is "upload a folder," and the security surface of the frontend collapses to almost nothing.
And critically: there is no serialization boundary inside my UI. The component tree runs entirely in the browser. It talks to the backend through fetch, against an API I designed on purpose. The boundary is visible in the code, at exactly the lines where it exists.
Here's what that looks like in practice. A route loads its data in a clientLoader which, despite living in a framework-mode route file, runs entirely in the browser and just calls my API:
// app/routes/projects.tsx
import type { Route } from "./+types/projects";
import { api } from "~/lib/api-client";
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
// Runs in the browser. No server. Just an HTTP call to my backend.
const projects = await api.projects.$get();
if (!projects.ok) throw new Response("Failed to load", { status: 502 });
return { projects: await projects.json() };
}
export default function Projects({ loaderData }: Route.ComponentProps) {
return (
<ul>
{loaderData.projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
There's no loader (server) here at all, only clientLoader. The seam is the api.projects.$get() call, and I can point at it. Auth is enforced the same way, with a clientMiddleware that runs before the loaders on protected routes:
// app/routes/dashboard.tsx - a protected layout route
import { redirect } from "react-router";
import type { Route } from "./+types/dashboard";
import { api } from "~/lib/api-client";
export const clientMiddleware: Route.ClientMiddlewareFunction[] = [
async ({ context }) => {
const res = await api.auth.me.$get();
if (!res.ok) throw redirect("/login");
context.set(userContext, await res.json());
},
];
But notice what this middleware is not: it is not my security boundary. It's a UX convenience; it bounces an unauthenticated user to the login screen so they don't stare at a broken page. If someone skips it entirely, they gain nothing, because every API endpoint enforces auth itself, server-side, on every request. This is the whole lesson of CVE-2025-29927 applied: client-side middleware is for experience, never for enforcement. The frontend can't bypass a check that the backend owns.
The backend side of that contract, in Hono, is explicit and inspectable:
// server/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { csrf } from "hono/csrf";
import { authMiddleware } from "./auth";
const app = new Hono();
app.use("*", cors({ origin: ["https://app.example.com"], credentials: true }));
app.use("*", csrf({ origin: ["https://app.example.com"] }));
// Real enforcement: the API itself rejects unauthenticated requests.
const projects = new Hono()
.use("*", authMiddleware)
.get("/", async (c) => {
const user = c.get("user"); // set by authMiddleware, trusted here
return c.json(await db.projectsForUser(user.id));
});
const routes = app.route("/projects", projects).route("/auth", authRoutes);
export type AppType = typeof routes; // <- exported for the frontend
That last line is the only reason I reach for Hono specifically. Exporting AppType lets the frontend build a fully typed client with no codegen step and no shared runtime:
// app/lib/api-client.ts
import { hc } from "hono/client";
import type { AppType } from "../../server";
// `api` is end-to-end typed against the backend routes.
export const api = hc<AppType>("/api", { init: { credentials: "include" } });
If the backend changes a route's shape, the frontend stops compiling. That's the type safety people credit good Next.js setups with, and I keep it without fusing the two halves into one runtime. Swap Hono for Go or Python, and I lose only this typed-client convenience; the architecture is unchanged, because the contract is just HTTP.
For data that loads after the initial render, a slow widget on an otherwise-ready page, I reach for Suspense and the use hook. This is the one genuinely good idea from the streaming era, and the nice part is it works perfectly well in a plain SPA, with no server rendering involved:
import { Suspense, use } from "react";
import { api } from "~/lib/api-client";
// Kick off the request; do NOT await it. `use` will suspend on the promise.
function ActivityFeed({ feedPromise }: { feedPromise: Promise<Activity[]> }) {
const activity = use(feedPromise); // suspends until resolved
return <Feed items={activity} />;
}
export default function Dashboard() {
// Promise created during render, in the browser. No RSC payload.
const feedPromise = useMemo(() => api.activity.$get().then((r) => r.json()), []);
return (
<>
<Header />
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed feedPromise={feedPromise} />
</Suspense>
</>
);
}
Same Suspense, same use, same streaming-feel UX of content arriving progressively, but the promise is an ordinary fetch resolving in the browser, not a chunk of a serialized server tree. There's no out-of-order flush to reason about, no hydration boundary, no payload format. I got the ergonomic win of the streaming era and left behind the serialized-tree machinery that came bundled with it.
Backend: a separate service - Hono if I want TypeScript
The backend is its own thing, deployed, scaled, and reasoned about independently. Right now, I mostly reach for Hono, because it's small, fast, runs anywhere from Node to workers to Bun, and lets me keep TypeScript end to end. With Hono, I can share types between client and server and even get a typed client, so I lose none of the type safety that good Next.js setups gave me.
But, and this matters the backend doesn't have to be JavaScript at all. It could just as easily be Go or Python. The reason that's now a free choice is precisely the serialization point from earlier: when your frontend is an SPA talking to an API over plain HTTP and JSON, there is no RSC payload, no server-action boundary, no shared-component-tree contract that forces both sides to be the same language. The contract is just HTTP. Go is fantastic for a backend. Python is fantastic for a backend. I use Hono specifically and only when I want the TypeScript type-sharing convenience; it's a preference, not a requirement. The architecture itself is language-agnostic, and that flexibility is a feature I gave myself by removing the fused boundary.
Security lives in the backend, on purpose
This is the part I care about most, and it's the direct lesson of CVE-2025-29927. Authorization and security are the backend's job, enforced at the API layer, every request, no exceptions. Not in framework middleware. Not coupled to a renderer's internal request plumbing. In the service that owns the data.
Concretely, on the backend, I run:
- Authentication as a real, deliberate layer of sessions or tokens, validated server-side on every protected request. The server, which owns the database, decides what you can see. The frontend is never trusted to enforce this; it only reflects it.
- CORS configured tightly - an explicit allowlist of origins, not a wildcard, so the browser only lets my actual frontend talk to the API.
-
CSRF protection - since a SPA-plus-API setup often uses cookies, I use proper anti-CSRF tokens and/or
SameSitecookie attributes so a malicious site can't ride a logged-in user's session. - CAPTCHA on the abuse-prone endpoints, signup, login, password reset, public form submissions to keep bots and credential-stuffing out.
- Plus the usual unglamorous hygiene: rate limiting, strict input validation at the API edge, security headers, secrets kept out of the client bundle, and least-privilege everywhere.
Every one of these is something I configure and can point at. There's no invisible header that flips my auth off, because my auth was never a header; it's a checked condition in the request handler that sits between the attacker and the database. If someone bypasses a routing layer, they hit the API, and the API still says no.
What I actually gained
Stepping back, here's the trade I made and why I'm happy with it.
I gave up: server-rendered dynamic pages out of the box, and the "it's all one project" convenience of a meta-framework.
I got back, in exchange:
- A legible architecture. I can point at any line and say which machine it runs on. The network boundary is one explicit place where the API, instead of being smeared invisibly through a component tree.
- No serialization tax. The UI tree lives entirely in the browser. No RSC payload, no server-action plumbing, no streaming-order puzzles.
- A tiny frontend attack surface. Static files on a CDN. Nothing to keep running, nothing to patch, no Server Functions to send crafted payloads at, nothing to catch the next 10.0 in the RSC packages.
- Security I can see. Auth, CORS, CSRF, CAPTCHA, and rate limiting all live in one service, enforced per request, decoupled from any renderer.
- Backend freedom. Hono today for the TypeScript ergonomics; Go or Python tomorrow if a project wants them. The HTTP contract doesn't care.
- Smaller blast radius. Two modest, separately-updated pieces with fewer heavyweight dependencies, instead of one large meta-framework whose single compromise is a very attractive target.
So, is Next.js bad? Is TanStack bad?
No. I want to end honestly, because tribal blog posts age badly.
Next.js and TanStack are both serious, well-engineered projects built by talented people. The CVEs and the supply chain attack I described are real and worth knowing about, but the CVEs got patched, and the TanStack incident was an account-and-pipeline compromise that could have hit almost any popular package. If your product genuinely needs server rendering for SEO on dynamic content, or you have a content-heavy site where streaming pays for itself, a meta-framework can absolutely be the right call. Plenty of teams ship great things on exactly the stack I walked away from.
My point is narrower and more personal. For the apps I build, authenticated products where the UI is interactive and the data is the crown jewels, the full-stack RSC model added a serialization-shaped boundary I didn't want, hid the seam I most needed to see, and nudged my security toward the framework's plumbing instead of my own backend. A SPA plus a separate, well-secured backend gave me a smaller, more legible, more boring system. And in software, boring is usually a compliment.
That's the round trip. I left, I looked around, and I came back to a plain SPA and a real backend, older ideas, held with sharper reasons.

























