Part one showed you can build a server-rendered React app without Next.js: five dependencies, pure CSS, server components fetching typed data directly. That was 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 runs in the same repo, own-stack (live here), and we checked each claim there.
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:
Loading diagram…
Mermaid source
flowchart TB
subgraph OLD["the usual full-stack tax"]
direction LR
C1["Client"] --> Q["query cache<br/>(TanStack / SWR)"] --> API["/api route"] --> TR["tRPC router<br/>+ schema"] --> DB1[("data")]
end
subgraph NEW["own-stack"]
direction LR
C2["Client island"] -->|"typed import"| SF["server function"] --> DB2[("data")]
end
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, so 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's 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 stopped asking. When the server boundary is a typed function call and the server owns the state, the cache you were synchronising 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 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 the whole category of "the UI disagrees with the database" goes with 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 something you reach for deliberately, and that includes the TanStack router-and-query pair that has 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, with its own icon and no address bar or browser chrome, and because the underlying stack is this quick, the line between "web app" and "native app" stops being something a user can feel.
The claim is narrower than native API parity, and a service worker doesn't replace 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: the abstractions we treat as the cost of doing business were load-bearing for a problem the platform has been taking apart. React 19 made the server boundary a typed function call, and that single change lets the rest fall away, down to 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.
