---
title: "Three surprises about server components"
slug: three-surprises-about-server-components
section: tech
date: 2026-07-03T16:38:00.000Z
canonical: https://roland.leth.ro/blog/tech/three-surprises-about-server-components
---

Server components are the best thing Next.js has done in years. Most of my code never ships to the browser, the few interactive widgets are visibly marked with `"use client"`, and the data layer is one fetch away from any page that needs it. That part of the App Router pitch is real.

The other part of the pitch ("you don't have to think about hydration anymore") has been less real. There are categories of bugs that exist only in the server-component model, where the framework is doing something on your behalf in the background that isn't obvious from the code you wrote. This post is about three of them. None are framework bugs; they're framework behaviors I'd have caught earlier with a closer reading of the docs, which is a kind way of saying I shipped them.

## Surprise 1: `<Link>` prefetches every visible row, not just on hover

The admin dashboard has a list of posts. Each row has an "Edit" link to `/admin/posts/{id}/edit`. The dashboard loaded fine; the editor pages loaded fine. But every dashboard load was firing dozens of background RSC requests, each running a Prisma query against the database.

The reason: in the App Router, `<Link>` prefetches as soon as the link enters the viewport, not when the user hovers over it. The dashboard renders a list of, say, 30 posts. Thirty rows enter the viewport. Thirty `<Link>` prefetches fire. Each prefetch is an RSC render of `/admin/posts/{id}/edit`, which calls `loadPostForAdmin(id)`, which hits the database.

The fix is two characters past the prop name:

```tsx
// src/components/admin/PostsTab.tsx
<Link
  href={`/admin/posts/${post.id}/edit`}
  prefetch={false}
  className="text-secondary hover:text-primary text-xs transition-colors"
>
  Edit
</Link>
```

`prefetch={false}` is the right setting for any per-row link in a list. The user is going to click at most one of them, but the framework prefetches all of them. The default is right for top-level navigation (the user is probably going to click the tab they're already aiming at) and wrong for repeating list items.

Pagination links and "New post" / "New project" buttons keep the default. Those are single navigations the user is likely-enough to take, and the prefetch is cheap.

The dashboard had been live for a while before I noticed, which is the part that stings. It didn't render slower or error; it just quietly multiplied the database load every time I opened it.

## Surprise 2: `<Link>` for `mailto:`, externals, and static files

The about page has links to email, Twitter, GitHub, and a few static assets. I'd reflexively typed `<Link>` for all of them, because that's the App Router's "use this everywhere" component for navigation. Reading the `<Link>` docs more carefully, I realized this is wrong on two counts.

`<Link>` is for in-app routes. The moment you point it at `mailto:`, an external URL, or a static asset:

1. The component still mounts as a client component on the page. A page that would otherwise be a pure server component now ships React to the browser to render a thing that, at runtime, is functionally just an `<a>` tag.
2. The prefetch behavior is undocumented for non-route hrefs. Sometimes it fires, sometimes it doesn't, sometimes it fires and 404s. I'd rather not be in the business of guessing.

The fix is to use the platform:

```tsx
// before
<Link href="mailto:roland@leth.ro">roland@leth.ro</Link>

// after
<a href="mailto:roland@leth.ro">roland@leth.ro</a>
```

The page is back to being a pure server component, the rendered HTML is unchanged from a user's perspective, and there's no client bundle for it anymore.

The rule I landed on: `<Link>` for routes the app owns, `<a>` for everything else, including when in doubt.

## Surprise 3: `unstable_cache` assumes JSON-serializable values

The Atom feed cache stored, among other fields, an `updatedAt` value from Prisma. Prisma returns timestamp columns as JavaScript `Date` instances. The feed handler did some math on those timestamps — `getTime()` calls to find the most recent — to compute the feed's `<updated>` element.

The math worked on cache miss and broke on cache hit.

The reason: `unstable_cache` JSON-serializes its return value. On cache miss, the cached function runs fresh and returns `Date` objects, which the handler can call `.getTime()` on. On cache hit, the same array comes back through `JSON.parse(JSON.stringify(…))`: the `Date` instances are now ISO strings, and `someString.getTime()` is a `TypeError`.

The framework errors on neither the write side nor the read side; it silently changes the type of the value across the boundary, and the call site that worked perfectly on cache miss explodes on cache hit. Cache miss is the only path you usually see in development.

The fix is to never put a non-JSON-serializable value into the cache in the first place:

```typescript
return Promise.all(
  posts.map(async (post) => ({
    title: post.title,
    slug: post.slug,
    section: post.section,
    datetime: post.datetime,
    updatedAt: post.updatedAt.toISOString(),                    // ❶
    summary: /* … */,
    htmlBody: await markdownToHtml(post.body),
  }))
)
```

`updatedAt` becomes a string before it ever reaches the cache ❶. Both cache miss and cache hit now return the same shape. The handler does its `new Date(post.updatedAt).getTime()` math on a string in both paths, which is fine; `new Date("…")` parses ISO.

The lesson I want to remember: anything that goes into `unstable_cache` should pass through `JSON.parse(JSON.stringify(…))` in my head first. If the resulting value would behave differently from the original, the cached path is going to drift from the uncached path, and I'll only find out on the second request.

## The good surprise: `React.cache()` for per-request dedupe

To end on the one server-component-shaped tool that has consistently surprised me in a good way: `React.cache()` (`import { cache } from "react"`).

A typical post page does two things: it renders the post body, and it sets metadata in `generateMetadata` for the `<head>` (title, OG image, description). Both need the post row. Without `React.cache()`, that's two database hits per request: one from the metadata pass, one from the page render.

```typescript
// src/lib/posts.ts
import { cache } from "react"

export const loadPost = cache(async (section: Section, slug: string) =>
  getPostBySlug(section, slug)
)
```

`cache()` from React (not `unstable_cache` from Next.js, different scope, different lifetime) deduplicates calls within a single render pass. `generateMetadata` calls `loadPost("tech", "some-slug")`; the page body calls `loadPost("tech", "some-slug")`; only the first one hits the DB. The second resolves to the same promise.

It's small, it's per-request, and it doesn't compete with `unstable_cache`'s persistent layer. It removes the duplicate work that the server-component model lets you accidentally introduce by structuring the page across two render functions; that's the case it exists for.

## What's next

[The next post][next] is about the rendering layer: how a markdown post becomes a styled page, and the small theme-toggle bug that taught me `useSyncExternalStore` properly.

There's presumably a fourth one waiting for me. [@roland.leth.ro][], if you know what it is.

[prev]: /blog/tech/200-markdown-posts-and-the-future-dated-content-puzzle "200 markdown posts and the future-dated content puzzle"
[next]: /blog/tech/three-markdown-pipelines-and-a-theme-toggle "Three markdown pipelines and a theme toggle"
[series]: /blog/tech/the-shape-of-a-next-js-app "The shape of a Next.js app"
[@roland.leth.ro]: https://bsky.app/profile/roland.leth.ro "@roland.leth.ro on Bluesky"
