NewPramaan now supports India data residency for litigation records.Talk to sales
PRAMAANLitigation record intelligence
Engineering
Engineering · ARCHITECTURE

Render on the server. Own the data on the client.

JUN 08 2026 · 8 MIN READ
TL;DR

A litigation associate spends the afternoon inside our matter list: six hundred matters, filter by court, search a party, sort by hearing date, fix a forum, dozens of touches a minute for hours. Every architecture we could pick hides a spinner somewhere in that afternoon. Server-render each interaction and it appears several times a minute. Move everything to the browser and it ambushes every cold load. And the first day the tool stutters, the associate exports the list to Excel, and every guarantee we sell leaves the building inside that spreadsheet.

So we refused to pick a side. The server renders the first paint with the data already in it, then seeds that data into a client cache which owns it from that moment on. Filters and sorts happen in memory. Mutations are optimistic. One targeted call when the truth actually changes. Next.js and TanStack Query sit side by side in our codebase because each is unbeatable at exactly one half of the job.

Render on the server. Own the data on the client.

A tool people live in

The screen our users spend the most time on is a list. Watch an associate work it for one minute on a hearing-heavy Tuesday: type "sharm" and the six hundred matters narrow with each keystroke; flip to the archived tab, counts change; sort by next hearing; open a matter, fix its forum, come back, and the list is exactly where they left it, re-sorted. That is one minute. They do it for three hours, and every step is them thinking with the screen, the way you think with a spreadsheet: reshape, glance, reshape again.

Now put a server round-trip inside each of those steps and do the arithmetic. Network out, database query, page render, network back: call it a few hundred milliseconds when everything is healthy, and it is not always healthy. At dozens of interactions a minute that is a visible stall several times a minute, every minute, all day. No single request fails. No error is ever logged. The tool is just sticky, the way a spreadsheet never is, and the user feels it in their hands long before they could name it.

That feel is not a polish concern for us; it is closer to a correctness one. Our real competitor on this screen is not another legal product, it is the exported spreadsheet. The first week the list stutters, an associate exports it to Excel "just for today," and works the copy. From that moment the firm is running on a snapshot: no live chronology, no audit trail, no fresh Brief, every guarantee the product makes quietly voided by one download. A spinner is how it starts. So we set the bar where the spreadsheet sets it: no loading state between a lawyer's thought and the screen's answer, ever, for the whole afternoon.

Which is why, when a code reviewer looked at our frontend and asked the obvious question, it deserved a real answer instead of a shrug. Next.js already renders on the server with the data in hand. Why is TanStack Query in this codebase at all?

Two speeds, one page

The question sounds like a technology choice. It is actually a confusion between two jobs that happen to share a page.

The first job is the first paint. Cold load, nothing on screen yet. Here the server is unbeatable: it sits next to the database, fetches the rows, and ships a page that arrives already full. Any client-side approach starts with an empty shell asking for permission to exist.

The second job is the thousand interactions after. The data is now on the screen. Narrowing it, re-sorting it, fixing one cell: none of that changes what is true on the server, so asking the server for each reshape buys nothing except the stall. That is the arithmetic of the last section. Death by a thousand spinners.

One page, two jobs, and the tools that win them are different. Most arguments about "server rendering versus client state" are two people each staring at one of the jobs and declaring the other one solved.

Reshaping via the server means a round-trip per thought; reshaping in memory is a cache operation

The options we worked through

Pure App Router. Server Components fetch, Server Actions mutate, revalidatePath refreshes. For a brochure page or a simple form this is exactly right, and we use it there. It falls apart the moment a page is a working surface. React Server Components are request-and-response; there is no client cache between requests, by design. Filter-as-you-type becomes a round-trip per keystroke or no filter at all. A one-row edit triggers revalidatePath, which re-renders the whole route segment to move one cell. Optimistic updates don't exist; the UI waits for the network before it moves. RSC's statelessness is what makes it simple and cacheable, and it is precisely the wrong property for a screen whose job is to let a user manipulate a working set in place.

Client-only. If interactions belong on the client, why not fetch everything in the browser and skip SSR? Because the first paint becomes a spinner. Mount, then ask, then wait, on every cold load. We'd have relocated the death-by-spinners to the start of every session.

Roll our own middle. useState, useEffect, a fetch, a hand-built cache, manual invalidation. We know how this movie ends, because the ending is famous: you have written a worse, untested copy of TanStack Query. When the honest design converges on a library that already exists, use the library.

What survived the whiteboard was a handoff, not a winner.

The handoff

Render on the server. Own the data on the client.

The server does the one thing it is unbeatable at. It fetches the list and renders the first screen with the rows already in it, then hands those rows to the client cache as its initial data. The cache starts life full. No fetch on mount, no flash of skeleton. The user sees a complete screen at server speed and never learns what a loading state looks like.

From that instant, ownership transfers. The client cache holds the working set, and every interaction is a cache operation: filter, search, and sort run in memory; an edit updates the cache optimistically and reconciles when the server confirms; a change that creates new truth makes one targeted call and invalidates exactly the one query it touched, so the table updates and the rest of the page never re-renders.

The handoff: the server fetches and renders once, seeds the client cache full, and the client owns the data from there

The mental model that finally made the split obvious to everyone in the room: a server-rendered snapshot is a photograph. To change anything in a photograph you have to retake it, and retaking it costs a round-trip. What the associate needs for the afternoon is not a photograph but the working set itself, in memory, reshapeable at the speed of thought, with the camera called back only when reality changes.

One more decision keeps the whole thing honest over time: the query and mutation hooks are generated from the backend's OpenAPI contract, not written by hand. The client cache and the server contract cannot silently drift, because they are two outputs of the same file. We hand-roll no fetches and hand-write no types.

What the split bought us

  • Cold load paints complete, at server speed, with zero client fetches.
  • Every filter, sort, and search during the working session is an in-memory operation. No network in the loop.
  • Mutations move the UI at the speed of intent, then reconcile.
  • A row edit re-renders a row, not a route segment.
  • The server's code stays boring: fetch, render, seed, done. All interaction complexity lives in one place, in a cache with well-understood semantics.

The cost is real and worth stating: two data paths to reason about instead of one, and a seeding seam that must stay correct (the server must fetch with the same key discipline the client uses, or the cache warms with the wrong shape). We took that trade knowingly. For a page someone visits once a day, we wouldn't.

Setting the table

The reviewer's question assumed the server and the client were competing for the same job. They never were. The server is unbeatable for exactly one paint, and wrong for every interaction after it; the client is the reverse. The architecture that felt like a compromise on the whiteboard turned out to be the whole point: render once on the server so the first screen is instant, then get out of the way.

The server is not competing with the client. It is setting the table.