---
title: "Different hydration and rendering strategies"
publishDate: 2026-06-28T00:00:00.000Z
excerpt: "Over the years, in our goal to achieve faster and faster web applications, we created different hydration and rendering strategies. Each with benefits and drawbacks that we explore in this article."
category: "performance"
tags: ["javascript", "performance", "reactjs", "nextjs", "astro", "tanstack"]
canonical: https://neciudan.dev/hydration-and-rendering-strategies
---

Dont worry, this article is not about the controversial Hydration breaks they are having during the World Cup matches. 

We want to discuss the hydration process in Server-Side Rendered applications, how other frameworks handle it, and similar rendering strategies that we apply to make our apps faster.

First, open a server-rendered page on a slow phone. 

The content paints almost instantly and looks finished. But tapping a button does nothing for 1 or 2 seconds. 

During that time, the page quietly downloads and runs JavaScript to rebuild everything you're already looking at.

That gap between a page that "looks ready" (HTML is shown) and one that "actually works" (interactive via JavaScript) is called hydration.

Every recent rendering strategy aims to shrink that gap.

## Static generation

Before reducing hydration, let's look at the alternatives. 

For many websites, the best approach is to render HTML once, ahead of time, rather than on every request.

If the page is the same for everyone—a blog post, a docs page, a marketing landing page, you are regenerating identical HTML thousands of times. 

Static Site Generation (SSG) renders each page once at build time, writes the result to an HTML file, and serves that file from a CDN. 

The server does nothing per request except hand over a file.

```tsx
// Next.js: this runs at BUILD time, not on each request.
export async function getStaticProps() {
  const posts = await loadPostsFromCMS();
  return { props: { posts } };
}
```

The obvious limit is freshness. 

If the HTML was built an hour ago, it's an hour stale. For a blog that's fine; for a product page with live pricing, it isn't. 

And the build itself becomes the bottleneck at scale: fifty blog posts can be built in seconds, but a hundred thousand product pages can take hours, and every content change means a rebuild.

That freshness problem is what Incremental Static Regeneration (ISR) solves.

You serve the static page as before, but you tell the framework to regenerate it in the background after a set interval, or on demand when the content actually changes.

```tsx
export async function getStaticProps() {
  const posts = await loadPostsFromCMS();
  // Serve the cached page, rebuild it at most once every 60s.
  return { props: { posts }, revalidate: 60 };
}
```

The first visitor after the interval triggers a quiet rebuild; everyone keeps seeing the last good version until the new one is ready. 

You get static delivery speed with content that's never more than a minute or two stale. 

One more variation you'll see is edge rendering. 

This isn't a different hydration model; it's SSR or ISR physically moved closer to the user, running on CDN edge servers scattered around the world rather than a single origin server. 

You do this to reduce latency: your dynamic HTML is generated a few milliseconds from the visitor rather than a continent away, with the trade-off that edge runtimes are more constrained in what they can do (limited Node APIs, tighter time and memory budgets). 

This method is perfect for static content, but what if our content is dynamic? 

## Client-side rendering

The simplest approach is to avoid rendering initial HTML on the server.

The server sends an almost empty HTML shell. This is just the basic structure of an HTML page, with a reserved section for your app, such as a '<div id="root"></div>' element, and a script tag that points to your JavaScript code. 

The browser then downloads the bundle, which combines all your app's code into a single file, runs your app, fetches any required data, and builds the entire user interface (UI) in the browser.

There's no hydration here because there's nothing to hydrate: the DOM (Document Object Model—the browser's representation of a web page's structure and content) was never server-rendered; it was constructed on the client from nothing. 

React just builds and attaches in one pass.

```tsx
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(<App />);
```

We call these apps Single Page Applications or SPA for short.

The main benefit of these apps is simplicity. You only think about the browser.

The downside is speed.

Picture a first-time visitor on a mid-range Android phone, using hotel Wi-Fi to tap your link. **They get a blank white screen**. 

That screen stays while the bundle downloads, then parses, and then executes. 

Next, it waits as it fetches the page's data. Only after all that does anything work. 

On your laptop with strong WIFI, you might not notice. 

On that phone, it's long enough for some visitors to close the tab before your app draws a single pixel.

Search engines hit the same wall. The Google bot crawler, requests your page and receives an empty <div id="root">, which may also have significant SEO implications.

That's why you want content in the initial HTML, and for that, we need the server.

## Server-side rendering

We render HTML on the server, send it to the browser, and have the browser hydrate it into a React app.

The user gets real content instantly, solving both CSR issues. The page isn't blank while JavaScript loads, and the crawler sees content.

```tsx
import { renderToString } from 'react-dom/server';
import App from './App';

const html = renderToString(<App />);
// send `html` wrapped in your document shell
```

On the client:

```tsx
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root'), <App />);
```

This is ideal for content that must rank and be interactive, such as news sites, blogs, marketing pages with signup forms, or product pages that need strong SEO content.

The downside: we repeat work. 

The server renders HTML; the client downloads React, rerenders, and compares the new HTML to the existing HTML. Until this second pass finishes, nothing on the page is interactive.

Traditional hydration runs top to bottom through the tree. If a heavy Comments section is near the top and the LikeButton below, hydration does comments first. 

The like button stays dead until hydration is done.

There is also a big problem: hydration mismatches. 

If server HTML and client HTML differ in any way, React detects it. 

In the worst case, it discards server HTML and re-renders on the client, recreating the blank-screen problem that SSR tries to solve, rendering all the benefits of SSR useless. 

Common causes are unassuming code:

```tsx
// Renders a different value on the server than in the browser:
<span>{new Date().toLocaleTimeString()}</span>

// Reads a browser-only API that doesn't exist on the server:
<div>{window.innerWidth > 768 ? 'desktop' : 'mobile'}</div>
```

Both look harmless but produce different server and client outputs, triggering hydration warnings.

If the difference is intentional, like a timestamp, React offers an escape: add suppressHydrationWarning, and React won't warn for that node. 

## Streaming SSR: send the page in pieces

The first improvement keeps hydration but stops making the user wait for the slowest part of the page before anything shows up.

Instead of rendering the whole tree to a string and sending it once it's complete, the server streams HTML in chunks as each part becomes ready. 

The tool for this is `Suspense`, a wrapper you put around a slow section that tells React, "show this fallback until everything inside is ready." 


```tsx
import { Suspense } from 'react';

function ProductPage() {
  return (
    <div>
      <ProductDetails />
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews />
      </Suspense>
    </div>
  );
}
```

The `ProductDetails` component renders immediately. 

The `Reviews` component, which might be waiting on a slow query, shows a skeleton, and the real reviews stream in when the data is ready. 

The user reads the product while the reviews are still loading.

React no longer has to hydrate top-to-bottom. Each `Suspense` boundary becomes an independent unit that can hydrate on its own, and React prioritizes based on the user's current activity. 

Click a button in a section that hasn't hydrated yet, and React rushes hydration of that boundary so the handler can run, ahead of the boundaries you're not touching.

The main benefit of this method is that perceived load time no longer ties to your slowest component. 

Go back to that product page. Without streaming, a three-second recommendations query means three seconds before anyone sees the product. 


But it's not perfect: Streaming changes the order and timing of the work, not the amount. 

Every interactive component on that page still gets downloaded and re-executed on the client; you've made the page feel faster without shipping a single byte less JavaScript.

It also hands you a new way to make things worse if you wrap too much of the page in one `Suspense`, and a single slow child holds that whole region back:

```tsx
// One boundary around everything: the fast summary now waits
// on the slow chart, because they share a fallback.
<Suspense fallback={<PageSkeleton />}>
  <Summary />   {/* fast */}
  <SlowChart /> {/* slow */}
</Suspense>

// Separate boundaries: the summary streams in immediately,
//The chart arrives on its own schedule.
<Summary />
<Suspense fallback={<ChartSkeleton />}>
  <SlowChart />
</Suspense>
```

There's also another method you'll see in several frameworks: progressive hydration. 

Rather than hydrate every boundary as soon as its code arrives, you defer a component until there's a reason to wake it up, when it scrolls into view, or when the user first interacts with it. 

A footer newsletter form three screens down doesn't need to hydrate during initial load; it can wait until you scroll near it. 

This still hydrates everything eventually, like streaming, but it spreads the work across time and skips parts the user never reaches. 


## Islands: hydrate only the interactive parts

Most of a typical web page isn't interactive at all.

A blog post is text, headings, and images, with maybe a comment widget and a newsletter form. 

A docs page is almost entirely static with a search box. Why ship and hydrate a JavaScript runtime for the 95% that's just sitting there as content?

Islands do this in reverse to SSR: The page is static HTML, and you explicitly mark the parts that need to come alive. 

Each interactive region is an "island" in a sea of static markup, and each hydrates independently, loading only its own JavaScript.

Astro is the framework that popularized this, and it makes the model concrete with directives. 

By default, a component renders to static HTML and ships zero JavaScript. You opt into interactivity per-component:

```astro
---
import Header from '../components/Header.astro';
import Newsletter from '../components/Newsletter.tsx';
import Comments from '../components/Comments.tsx';
---

<Header />
<article>
  <h1>My post</h1>
  <p>This is just text. It ships as HTML, no JavaScript.</p>
</article>

<Newsletter client:visible />
<Comments client:visible />
```

That `client:visible` is a loading contract, and it's the progressive hydration idea from earlier. 

It tells Astro to render the component to static HTML first, then hydrate it only when it scrolls into view. 

The directives cover the common cases: `client:load` hydrates immediately for above-the-fold interactivity, `client:idle` waits until the browser is idle, `client:visible` waits until the component enters the viewport, and `client:only` skips server rendering entirely for components that depend on browser-only APIs. 

Islands take progressive hydration and add the crucial second half: the parts you never mark stay static forever and ship no JavaScript at all.

If the user never scrolls to the comments, the comment widget's JavaScript never loads.

This fits content-heavy sites almost perfectly. 

Blogs, documentation, marketing pages, news, and e-commerce category pages. Anywhere the page is mostly content with islands of interactivity rather than the other way around. 

The framework built around this, Astro, ranked highest in the meta-framework satisfaction category of the State of JS 2025 survey, and Cloudflare acquired it in January 2026. (And if you haven't read my previous articles, this blog is also using Astro, [checkout my previous article where I upgraded to Astro 6](https://neciudan.dev/astro-upgrade-from-3-to-6) and took advantage of all the cool benefits)

The practical result is that you land a Lighthouse score in the 90s without doing anything clever, because there's barely any main-thread work for the browser to do. 

And since each island is self-contained, you're not even locked to one framework; you can drop a React island next to a Svelte one on the same page.

There's a server-side version of the same idea. Astro's `server:defer` turns a component into a server island: the static shell ships immediately and is aggressively cached, while a personalized piece, such as a signed-in user's avatar or cart, renders per-request on the server and slots in without holding up the rest of the page. 

You get to cache the 95%, that's the same for everyone, and still serve the 5% that isn't.

But islands assume a mostly static page with sprinkles of interactivity, and they're wonderful until that assumption breaks. 

Imagine trying to build Figma, or a trading terminal, or any app where nearly everything on screen is interactive, and pieces share state across the whole layout. 

You'd be marking the entire page as one giant island, threading shared state between islands that were designed to be isolated, and fighting the architecture the whole way.

At that point, you've reinvented SSR with extra steps.

So islands win when interactivity is the exception. For an app where interactivity is the rule, you need a different weapon. 

## React Server Components: don't send the component to the client

A Server Component runs only on the server, which means its code is never sent to the browser. 

It can hit the database directly, read the filesystem, use secrets, and what it ships to the client isn't JavaScript; it's a serialized description of the UI it produced, in a format React calls Flight. 

There are now two streams in play: the HTML stream from the streaming section, which is the markup the browser paints, and this Flight stream, which is a serialized component tree the client reconstructs. 

RSC uses both. There's nothing to hydrate for the server parts because there's no component code on the client to re-execute.

Components are server-only and weightless on the client. Components marked `"use client"` ship JavaScript and hydrate.

```tsx
// Server Component, runs on the server, ships no JS
async function ProductPage({ id }) {
  const product = await db.product.findUnique({ where: { id } });

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}
```

```tsx
'use client';

// Client Component, this is the part that ships JS and hydrates
function AddToCartButton({ productId }) {
  const [adding, setAdding] = useState(false);
  // ...
}
```

The product name and description are server-rendered and never become JavaScript on the client. 

Only `AddToCartButton` is visible in the browser.

This is the model Next.js is built around now, and it supports full applications that also include a lot of server-generated, non-interactive content.

The main benefit is that you stop shipping code that the browser will never use. 

In that example, your database call and the markup it produces stay on the server; the only JavaScript that reaches the user is `AddToCartButton`. 

You also get to query the database right inside the component, no `/api/products/:id` route to build, no `fetch` to wire up, no loading state to manage by hand. 

Layer streaming and selective hydration on top, which RSC does for free, and you get a fast first paint, a small bundle, and direct data access in one model.

React 19.2 sharpened the tools in this area. Partial Pre-Rendering lets you render a static shell at build time, cache it at the CDN edge, and stream the dynamic holes per-request, so a single page can be mostly statically cached with a personalized slot or two. 

Suspense reveals are batched to match client behavior, and hydration mismatch errors finally name the component that caused them instead of leaving you a cryptic warning.

Although the line between server and client isn't free, and one `"use client"` in the wrong place quietly drags a whole subtree into the browser bundle. 

Like this:

```tsx
'use client';

import { HeavyChart } from './HeavyChart'; // also becomes client code

```

You added `"use client"` for a single button, and because a client file pulls its imports into the client graph with it, a heavy chart you meant to keep on the server is now in your bundle. "use client creep" is a real failure mode in production codebases.

On top of that, RSC ties you to a framework and a server runtime that uses them; it isn't something you sprinkle onto a plain SPA you already have.

Also, moving rendering to the server increases your attack surface there, too. 

Server Components execute on the server, serialize a payload, and the client deserializes it; each of those steps now runs in a privileged place with access to your database and secrets. 

And in late 2025, a serious RSC deserialization vulnerability (CVE-2025-55182) was found being actively exploited and had to be patched across several React and Next.js versions. 

Doing this on the server could have a lot of security holes you need to keep an eye on.

## TanStack Start: the same primitive, ownership flipped

The Next.js model is server-first. 

TanStack Start inverts that. The client stays in charge, and RSCs are demoted from a paradigm to a data type.

The idea is that an RSC payload is just a stream. Specifically, a React Flight stream is the serialized description of UI that the server produced. 

TanStack Start treats it as exactly that: bytes you fetch over HTTP, on the client's terms, whenever you want them, rather than a server-owned tree the framework hands you by default.

You create the server-rendered UI in a server function and load it via a route loader.

Nothing gets marked `"use client"` to opt *out* of the server; you opt *in* to the server only where you want it.

```tsx
import { createServerFn } from '@tanstack/react-start';
import { renderServerComponent } from '@tanstack/react-start/rsc';

function Greeting() {
  return <h1>Hello from the server</h1>;
}

const getGreeting = createServerFn().handler(async () => {
  return { Greeting: await renderServerComponent(<Greeting />) };
});
```

Because the payload is just data, it drops into the caching tools you already use. There's no special "RSC mode." 

Wrap the server function in a TanStack Query call, and the RSC payload gets cache keys, `staleTime`, and background refetching like any other query. 

For static content, set `staleTime: Infinity` and you're done.

```tsx
function Greeting() {
  const query = useQuery({
    queryKey: ['greeting'],
    queryFn: async () => createFromReadableStream(await getGreeting()),
  });

  return <>{query.data}</>;
}
```

It's a different answer to the exact same question RSC asks: how do you avoid shipping component code that didn't need the browser? 

Next.js answers at the framework level, server-first, all-in. 
TanStack Start answers at the data level, is client-first, and is opt-in. 

This fits a situation the server-first model handles badly: an existing client-side app, an admin portal or a SaaS dashboard, that wants to move *some* heavy rendering to the server without rewriting its architecture around a new paradigm. 

Markdown parsing, syntax highlighting, a search index, anything heavy and static you'd rather not ship. 

You sprinkle in RSCs where they pay off and leave the rest of the SPA alone. 

When TanStack migrated the content-heavy parts of their own docs site to this, blog, and docs pages, each dropped around 153 KB gzipped from the client JS graph, and Total Blocking Time fell from about 1,200ms to 260ms.

You don't have to rewrite your app to try RSCs, but you have to do a lot of the wiring up yourself. 

The server-first model's all-in nature is also what makes streaming, boundaries, and colocated server work feel built in; when you opt into each piece by hand, you own the composition that Next.js provides out of the box. 

And TanStack Start is younger, with its RSC support still stabilizing toward 1.0, so for a large production app, you're taking on a less settled bet than Next.js in exchange for that flexibility.

RSC shrinks the hydration surface to the interactive leaves, no matter how you slice ownership. 

But those leaves still hydrate. They still download, re-execute, and rebuild their slice of the tree on the client. 

The remaining question is whether even that is necessary, and there are two different answers.

## Fine-grained reactivity: hydrate once, then never re-render

React is coarse-grained. When the state changes, the component that owns it reruns, and React walks down from there, diffing the virtual DOM to figure out what actually changed in the real one. 

Hydration is expensive partly because it's this same re-render machinery running for the first time across the whole tree.

SolidJS and Svelte do things differently. 

There's no virtual DOM and no re-rendering. A component runs once to set up a reactive graph, wiring each piece of state directly to the specific DOM node it controls. 

When a state changes, the framework updates that one text node or attribute. 

```tsx
import { createSignal } from 'solid-js';

function Counter() {
  const [count, setCount] = createSignal(0);
  // This function body runs ONCE. The signal is wired.
  // straight to the text node below.
  return <button onClick={() => setCount(count() + 1)}>Clicked {count()} times</button>;
}
```

That `Counter` function executes only once, during setup. Clicking the button updates the count signal, which updates exactly one text node. 

The component never runs again.

Because there's no re-render machinery, hydration is dramatically cheaper. Solid still incurs a setup cost to wire the reactive graph to the server-rendered DOM, but it's not re-executing and diffing the whole component tree, so the work is a fraction of what React does during hydration. 

Svelte 5 sits in the same territory: its runes system (`$state`, `$derived`) moved Svelte to a signal-based, fine-grained reactivity model, close to Solid's, and State of JS 2025 credited exactly that change as the year's standout in developer experience.

The newest face in this family is Ripple, an experiment from Dominic Gannaway (who worked on React Hooks at Meta and was on Svelte 5's core team). 

It's a TypeScript-first compiled language with the same no-virtual-DOM, surgical-update model, reactivity built on a `track()` primitive. 

These frameworks fit performance-critical interactive UIs where things update constantly. 

Real-time dashboards with hundreds of live data points, trading interfaces, data grids, and animation-heavy apps targeting a steady 60fps. 

Anywhere React's re-render-and-diff cost compounds across every update on a busy screen.

Of course, React's download numbers dwarf all of these new frameworks, and that gap is libraries, AI advice and help, and the odds that your next hire already knows the framework. 

The meta-frameworks are less settled, too: SvelteKit is solidly production-ready, while SolidStart hit 1.0 but is still filling in deployment adapters and documented patterns, so you'll more often be the first person to hit a given edge case. 

And the mental model differs from React:

```tsx
// React: this function re-runs on every render, so it's expensive`
// is recomputed each time.
function Row({ value }) {
  const expensive = computeStuff(value);
  return <td>{expensive}</td>;
}

// Solid: this function runs ONCE. If you write it the React way,
// `expensive` is computed a single time and then never updates
// when `value` changes. You have to reach for a derived signal instead.
```

The code looks like React. It does not behave like React. 

Fine-grained reactivity makes hydration cheap. But it still hydrates; there's still a setup pass that runs on the client to connect the graph to the DOM before anything is interactive. 

The last strategy asks whether you can skip even that.

## Resumability: don't hydrate at all

The final move is the strange one, and it needed a new word because it isn't a faster hydration. It's the absence of hydration.

Every strategy so far, even the leanest island, shares one assumption: the client has to execute *some* JavaScript to make the server-rendered HTML interactive. 

Re-run the components, or at least run a setup pass to wire up the reactive graph.

Qwik's argument is that throwing the work away is the actual mistake. Instead of serializing only the HTML and reconstructing everything else on the client, serialize the entire framework's execution state, component boundaries, event listener locations, and the reactivity graph directly into the HTML. 

The client doesn't rebuild anything. It picks up exactly where the server paused.

The mechanism is visible in the markup. Event handlers aren't attached during a startup pass; their locations are serialized as attributes pointing at lazy-loadable chunks:

```html
<button on:click="./chunk-abc.js#handler">Add to cart</button>
```

The only thing that runs on boot is a tiny script called the Qwikloader, a fraction of a kilobyte, which registers one global event listener and does nothing else until you interact. 

When the user clicks that button, and only then, the Qwikloader resolves the attribute, downloads the specific handler chunk, rehydrates just the state that handler needs, and runs it. 

The code for a given interaction loads when the interaction occurs, not before.

But serializing execution state into the HTML imposes limits on what can be serialized, and those limits constrain how you can write components; you can't just stuff anything into a closure and expect it to resume. 

Loading code per interaction also means more small requests rather than one big upfront bundle, which speculative prefetching and HTTP/2 are designed to hide, but it's a different performance profile you'll need to reason about and tune rather than ignore.

And Qwik is the one framework built entirely around this idea, making it the newest and least battle-tested option in this article, with the smallest ecosystem to lean on.

There's also a case where the payoff simply isn't there. 

If you're building a video editor or a live dashboard, the user interacts with almost everything almost immediately, so you end up loading most of the code in the first few seconds anyway. 

Resumability's advantage is deferring code the user might never reach; when they reach all of it, you've taken on the complexity and gotten little of the benefit.

## Next.js 16.3: closing the last gap with instant navigations

Everything so far has been about the first load: how fast the page paints, how much it costs to make it interactive. 

But there's a second moment that decides whether an app feels like an app, and it's the one server-driven models have always been worst at: what happens when you click a link to the next page.

In a classic server-driven app, that click means a network round-trip. 

You click, nothing happens, the server responds, and the next page appears. 

For a blog that's acceptable. For anything that feels like software, that pause is exactly what makes people say server apps feel "like a website" rather than an app. 

A client-driven SPA hides the pause: you click, you instantly see a shell of the next page with data still loading, then the rest fills in. 

That instant shell is most of why people reach for SPAs even when the server model would serve them better everywhere else.

Next.js 16.3 aims to give the server model the same instant-shell feel, and the way it gets there ties together two things already in this article: Partial Pre-Rendering and the Flight payload that RSC streams to the client.

The whole feature is currently gated behind one flag:

```ts
// next.config.ts
const nextConfig = {
  cacheComponents: true,
};
```

Turning on Cache Components changes the rendering model. 

Every route is dynamic by default, with no implicit caching, and PPR becomes the default rather than an experimental per-route opt-in. 

From there, whenever a route `awaits ' data on the server, you're making one of three choices about that piece of the page.

You can stream it by wrapping the slow part in `<Suspense>`, and the user instantly sees the static shell with a loading state where that part will go.

You can cache it by marking the work with `use cache`, and the user instantly sees a previously cached version of that UI, reused across requests.

```tsx
async function ProductList() {
  'use cache';
  cacheLife('hours');
  const products = await getProducts();
  return <ProductGrid products={products} />;
}
```

Either of those makes the navigation feel instant, because nothing the user is waiting on blocks the shell. 

The third choice is to block on purpose. Some routes shouldn't show a loading shell, a blog post that should arrive whole rather than skeleton-first, and you opt that route out explicitly:

```tsx
// page.tsx
export const instant = false;
```

Next.js 16.3 also brings a new trick: rather than prefetching a page per link, it prefetches one reusable shell per route and caches it on the client for the session. 

Twenty links to `/chat/[id]` now prefetch a single `/chat/[id]` shell, the same way a SPA ships one piece of per-route code and reuses it for every item. 

You can enable it alongside Cache Components:

```ts
// next.config.ts
const nextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};
```

That shell is the static, cacheable part of the route, the part PPR already knows how to pre-render.

So the two flags are really one idea seen from two ends: PPR decides what part of a route is a static shell, and Partial Prefetching ships exactly that shell to the client ahead of the click.

Because the shell serves as the baseline, prefetching is no longer an all-or-nothing proposition. 

If you want a particular link to arrive with more than the bare shell, a chat header that should pop in immediately, you opt that link into deeper prefetching, and Next.js renders down to whatever is synchronous or marked `'use cache'`:

```tsx
<Link href={`/chat/${id}`} prefetch={true}>
  {title}
</Link>
```

This fits exactly the kind of app the server model used to feel wrong for: dashboards, chat apps, anything with a dense sidebar of links where every click used to mean a visible pause. 

Vercel has been running it on v0, where rich client features made navigation feel sluggish, and used the new dev-time insights to hunt down the routes that weren't navigating instantly.

You get the SPA's instant-click feel without giving up the server-centric model, the small client bundle, the direct data access, the crawlable first load, all the things the RSC section was about. 

The problem is that "instant" stops being free and becomes something you maintain. 

Every dynamic piece of every route is now a Stream/Cache/Block decision you have to make and keep correct as the app changes; a refactor that moves a `cookies()` read out of its `<Suspense>` boundary quietly turns an instant route into a blocking one. 

Next.js leans on tooling to hold the line here, the dev-time error, a Navigation Inspector that pauses navigations at the shell so you can see what's prefetched, and an `instant()` Playwright helper that asserts what must be visible before the network responds:

```ts
import { instant } from '@next/playwright';

test('next page shell is instant', async ({ page }) => {
  await page.goto('/products/shoes');
  await instant(page, async () => {
    await page.click('a[href="/products/hats"]');
    await expect(page.locator('h1')).toContainText('Baseball Cap');
  });
});
```

It's also preview-only and flag-gated as of mid-2026, with both `cacheComponents` and `partialPrefetching` planned to become defaults in a future major version.

## How to actually choose

The only thing that matters is how much client-side work happens between "HTML painted" and "page interactive."

For most people most of the time, the honest answer is boring. 

If you're building a content site, reach for static generation and islands; Astro will give you near-perfect performance with almost no effort. 

If you're building an app, React with Server Components on a framework like Next.js will carry you a very long way, and the streaming and selective-hydration machinery comes along for free.

The rest are tools you reach for when a specific problem actually calls for them. 

Fine-grained reactivity when updating performance on a busy screen is the cause of the issue. 

Resumability when you're large enough that hydration costs have become the bottleneck, and the first interaction must be instant. 

CSR when the page is behind a login, and nobody's first paint matters.

Nobody's site ever got slow because they server-rendered some HTML and hydrated it. 

It's always the other direction: a content blog shipping a full app runtime, an app fighting hydration cost it never measured, a marketing page blank for two seconds while a bundle loads.

Match the strategy to how much of your page is actually interactive, and reach for something more exotic only when you can name the problem it solves.

## References

- [Resumable vs. Hydration](https://qwik.dev/docs/concepts/resumable/) - Qwik docs, on serializing execution state instead of replaying it
- [Islands Architecture](https://docs.astro.build/en/concepts/islands/) - Astro docs, on client and server islands
- [Client Directives](https://docs.astro.build/en/reference/directives-reference/) - Astro docs, on the `client:*` loading contracts
- [Resumability vs Hydration](https://www.builder.io/blog/resumability-vs-hydration) - Builder.io, comparing React, Solid, and Qwik on the spectrum
- [New Suspense SSR Architecture in React 18](https://github.com/reactwg/react-18/discussions/37) - React working group, on streaming and selective hydration
- [How Wix Solved React's Hydration Problem](https://www.wix.engineering/post/40-faster-interaction-how-wix-solved-react-s-hydration-problem-with-selective-hydration-and-suspen) - Wix Engineering, a production selective-hydration case
- [Server Components](https://react.dev/reference/rsc/server-components) - React docs
- [React Server Components Your Way](https://tanstack.com/blog/react-server-components) - TanStack blog, on the client-first, RSCs-as-streams model
- [How to choose the best rendering strategy](https://vercel.com/blog/how-to-choose-the-best-rendering-strategy-for-your-app) - Vercel, on SSG, ISR, SSR, CSR, and per-route mixing
- [Ripple](https://github.com/Ripple-TS/ripple) - the newest fine-grained, compiler-driven TypeScript framework
- [State of JavaScript 2025: Meta-Frameworks](https://2025.stateofjs.com/en-US/libraries/meta-frameworks/) - satisfaction rankings for Astro, Next.js, and others
