Farewell, Next.js: Server-Rendered React in Five Dependencies
For years the answer was Next.js, and it earned it. But server-rendered React still came with a quiet tradeoff: fast first paint, or app-like speed — rarely both. That's the part that just changed. You can now have both at once — instant load and app-smooth navigation — in a stack small enough to actually own.
Here's the thing that changed and almost nobody acted on: React 19 turned server rendering into a primitive. Server Components, server functions, streaming — those used to be things a framework did for you. Now they're things React does, and you can hold them directly. So we asked a simple question and answered it with running code instead of opinions: how small and how yours can a server-rendered React stack be?
We built it. It's own-stack, and everything below is something the repo actually does.
The whole thing, in numbers
Five production dependencies. Fifteen files. About four hundred lines for the complete demo app — home page, a server-rendered feed, and a guestbook with a real mutation.
That's the entire stack:
"dependencies": {
"hono": "...",
"react": "...",
"react-dom": "...",
"react-server-dom-webpack": "...",
"waku": "..."
}
Waku is the only framework here — a deliberately minimal React-Server-Components meta-framework (from the author of jotai and zustand). It gives you SSG, SSR, and file-based routing, and then gets out of the way. Everything else is just React.
How a request actually flows
The shift worth internalizing: the component is the server now. There's no client that fetches from an API that talks to a database. The page runs where the data is.
Types across the wire, for free
The piece people reach for tRPC to solve — sharing types between server and client — mostly evaporates. A Server Component imports a server function and awaits it:
// the data layer is a plain typed module
export async function getFeed(): Promise<FeedItem[]> { /* db, api, anything */ }
// the page awaits it directly — `items` is FeedItem[], no glue
const items = await getFeed();
There is no API route, no schema, no codegen, no client. The import is the contract. Mutations work the same way through a server action: a "use server" function the client calls like a local async function, with the signature enforced on both ends. We have a guestbook in the repo doing exactly this — a typed write across the network with no API layer in sight.
And the developer experience is the payoff you feel immediately: the boilerplate is gone. No app/api/feed/route.ts, no fetch wrapper, no query hook, no Zod schema duplicated on both sides, no generated client to keep in sync. You write a function and you call it. Interactivity is opt-in the same way — a page is server-rendered by default, and you reach for a "use client" island only where something genuinely needs to be interactive, the exception rather than the tax on every route.
The mental model is subtraction. No API layer (Server Components fetch directly). No tRPC (the import is the type). No build-time codegen (types flow through imports). No Tailwind (one semantic stylesheet). You add interactivity deliberately, instead of removing weight you never chose to carry.
On styling we kept our house rule: pure semantic CSS, no Tailwind, no CSS-in-JS. The Waku starter ships with Tailwind; removing it was three files and a cleaner stylesheet. Style the primitives, let the cascade work.
The part that scales: the server owns the state
There's a quieter consequence here, and it's the one that matters in production. In the classic React app, state lives in two places at once: on the server, which holds the truth, and in a client cache that has to be kept in agreement with it — TanStack Query, SWR, a Redux store, pick your instrument. That synchronization layer is where a startling share of the bugs live, and it's an entire tier you design, ship, and operate.
In this model the server is the store. A Server Component reads data where the data already lives and renders it; a server action mutates it and the page re-renders from that same source of truth. There is no second copy to drift, no cache to invalidate, no "why is the UI stale" class of bug — because there is only one place the state was ever kept.
That collapses the infrastructure as much as the code. No client cache means no separate API tier whose whole job is to feed that cache; fewer services to deploy, fewer things to scale on their own curve, fewer failure modes strung between the database and the pixel. The stack gets smaller exactly where production systems usually metastasize. Scaling stops being "add another tier" and becomes "the one tier you have does more."
The honest frontier: auth
This is where owning your stack runs into a young ecosystem. I won't pretty it up.
We wanted Better Auth — a framework-agnostic, TypeScript-native auth library that lives inside your app instead of behind a vendor. It's exactly the right shape for a stack like this. But mounting any arbitrary request handler in Waku today doesn't work through the obvious file convention — we probed it and got a clean 404. It needs Waku's programmatic createApi. In Next, auth is a documented drop-in; here, the API and auth layer is still where you wire the plumbing yourself.
That's the real trade. Owning your stack means meeting the edges of a framework that's young precisely because it's minimal. The RSC core is mature and genuinely delightful. The API ergonomics are a frontier — and a fast-moving one.
What we'd ask you to take from this
The default isn't a law. "Server-rendered React" stopped being synonymous with "a single framework that owns the whole thing" the moment React 19 shipped Server Components as a primitive. You can now assemble a stack that is small, typed end-to-end, scales by removing tiers rather than adding them, and is yours — and the parts that aren't ready yet (auth ergonomics, API mounting) are knowable, not hidden.
We're not telling you to rip out Next tomorrow; for many teams its maturity is the right call today. We're saying the assumption deserves re-examination, and the building blocks are finally good enough to examine it honestly. The starter is open and runnable — clone it, delete what you don't need, and see how little is actually left. That subtraction is the whole point.
And we're only halfway. A frontend this small and this typed is a good place to stand — but it's not the best part. The best part is what happens when you carry the same idea across the entire stack: end-to-end type-sharing between client and server with no tRPC, no codegen, and none of the heavy router-and-query abstractions everyone reaches for by reflex — yes, including TanStack. Push that far enough and something strange happens: the app gets fast enough to install on a phone, and you lose the ability to tell which apps are "native." That's where we're going next. Part two is the full-stack half of the same bet — and we're building it in the same repo, in the open.
Get weekly intel — courtesy of intel.hyperdrift.io