Farewell, Next.js — Part Two: Types All the Way Down
Part one ended on a claim and a cliffhanger. The claim: you can build a server-rendered React app without Next.js — five dependencies, pure CSS, server components fetching typed data directly. The cliffhanger: that's the easy half. The frontend was never where the real tax lived.
The tax lives at the seam between client and server. It's the layer where your types stop being types and become a hopeful contract you maintain by hand: an API route here, a request schema there, a generated client to keep in sync, a query cache to keep in agreement with the database. We reach for tools to manage that seam — tRPC, TanStack Query, a code generator — and we call the resulting weight "just how full-stack works."
This piece is about what happens when you stop believing that. We carried Part one's idea across the whole stack, and the seam mostly disappeared. Then something we didn't plan fell out of it: the app became installable, and on a phone you can't tell it from native. Everything below is running in the same repo — own-stack, live here — and was checked, not asserted.
The seam, and what we usually pay to cross it
Here is the shape of a conventional typed full-stack request, and the shape of the same thing in own-stack:
The top row is four moving parts whose entire job is to carry a type and a value across one boundary. Each one is a place the type can drift from the value: the schema disagrees with the database row, the generated client lags the router, the cache serves something the server already changed.
The bottom row is the same request with the boundary treated as what it now is — a function call.
Client-side fetching without the cache
Part one showed a server component awaiting a typed function. The objection writes itself: that's the easy case — what about data you fetch after load, on the client, in response to a user? That's the job everyone hands to TanStack Query.
In own-stack it's a server function the client imports and calls:
// queries.ts — a 'use server' module
export async function searchFeed(query: string): Promise<FeedItem[]> {
const all = await getFeed();
return filterFeed(all, query);
}
// a 'use client' island — the job people give TanStack Query
const [items, setItems] = useState(initial);
const [pending, startTransition] = useTransition();
function onSearch(query: string) {
startTransition(async () => {
const next = await searchFeed(query); // next is FeedItem[] — no annotation
setItems(next);
});
}
That's the whole thing. searchFeed runs on the server; the client calls it like a local async function and React ships the call across the wire. next is FeedItem[] because the import carries the type — there is no API route to define, no tRPC router to register it on, no schema to duplicate, no generated client, and no query library holding a cache. useTransition gives you the pending state you'd have reached for a hook to get.
We typed react into that box and got back exactly the two React items; auth returned the one auth item. The round trip is real, and it's typed end to end from a single function signature.
The reflex is the cost. tRPC and TanStack Query are excellent answers to a question React 19 quietly stopped asking. When the server boundary is a typed function call and the server owns the state, the cache you were synchronizing and the router you were generating are both solving a problem you no longer have.
Why this holds: the server is the store
This is the same point Part one made about scaling, seen from the client. A query cache exists because state lived in two places — the server's truth and a client copy — and something had to reconcile them. own-stack keeps state in one place. searchFeed doesn't read a cache; it asks the server, and the server reads the data where the data lives. There is no second copy to go stale, so there is nothing to invalidate.
Delete the cache and you delete the whole category of "the UI is showing something the database doesn't agree with" — not by managing it well, but by not having it.
The honest edge
This is not "you never need tRPC again," and pretending so would repeat the mistake Part one was careful to avoid. Server functions cover client-to-your-server calls inside one React app. The moment you have a consumer that isn't this app — a public API, a mobile client, a partner integration — you want an explicit, versioned contract, and a thin typed layer (oRPC, ts-rest) earns its place. Heavy optimistic UI and live collaboration still have real state to coordinate on the client.
What changed is the default. The abstraction is now the exception you reach for deliberately, not the tax you pay on every app by reflex — including, yes, the TanStack router-and-query stack that's become a reflex install.
The part we didn't expect: it installs like native
Here's what falls out of keeping the stack this light. The pages are server-rendered and small; the server is close to the data; there's no client cache to warm. The thing is fast. And a fast, server-rendered app clears the bar to be a real installable web app almost for free.
It took a manifest, a thirty-line service worker, and one button:
// the install action — no library
const onClick = async () => {
await deferredPrompt.prompt(); // the browser's native install sheet
await deferredPrompt.userChoice;
};
The service worker is network-first for navigations — so every launch is a fresh server render — and cache-first for assets, so a dropped connection doesn't blank the screen. We didn't reach for Workbox or a PWA plugin; the whole worker is readable in one sitting.
We verified it the way we verify everything here: the service worker registers and activates, the manifest is valid, and Chrome's own installability check returns zero errors. On Android the browser offers "Add to home screen"; on iOS it's Share → Add to Home Screen. Either way the app launches standalone — no address bar, no browser chrome, its own icon — and because the underlying stack is this quick, the line between "web app" and "native app" stops being something a user can feel.
Be precise about the claim: this isn't native API parity, and a service worker isn't a replacement for everything a platform gives you. It's that the experience — launch, standalone window, responsiveness — is indistinguishable from native for the kind of app most products actually are. That used to require a framework, a build target, and a wrapper. Here it's a manifest and a button on top of a stack you already own.
What we'd ask you to take from this
Two articles, one bet: that the abstractions we treat as the cost of doing business — the meta-framework, the API tier, the typed-RPC layer, the client cache, the PWA toolchain — were load-bearing for a problem the platform has been quietly dismantling. React 19 made the server boundary a typed function call. That single change lets the rest fall away: no Next to move into, no tRPC to define, no TanStack to install, no cache to synchronize — and a result light enough to live on a home screen.
We're not claiming this is the right call for every team today; maturity still matters, and we marked the edges where it doesn't yet reach. We're claiming the default deserves to be re-derived from scratch, because the building blocks finally reward it. The stack is open and runnable — or try it live: type into the search box, install it on your phone, and delete whatever you find you no longer need. There's less there than you'd expect. That was always the point.
Get HyperDrift signal — courtesy of intel.hyperdrift.io