Three surprises about server components
Server components are the best new 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.
<Link> prefetches every visible row, not just on hoverThe 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:
// 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 thing I want to remember: the dashboard had been live for a while before I noticed. It didn't render slower. It didn't error. It just quietly multiplied the database load every time I opened it.
<Link> for mailto:, externals, and static filesThe 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:
<a> tag.The fix is to use the platform:
// 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. There's no "use <Link> when in doubt"; if it's not a route, <a> is correct.
unstable_cache assumes JSON-serializable valuesThe 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.
On cache miss the math worked. On cache hit it didn't.
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 doesn't error on the write side. It doesn't error on 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:
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.
React.cache() for per-request dedupeTo 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.
// 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. 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, which is the whole reason it exists.
The next post is about the rendering layer: how a markdown post becomes a styled page, and the small theme-toggle bug that taught me useSyncExternalStore properly.
If there's a fourth surprise I should know about before I hit it, @rolandleth.