The Upgrade Journey
When Next.js 16 introduced Cache Components and Partial Prerendering (PPR), it represented a significant shift in how applications handle caching and rendering. The portfolio site was running on Next.js 15, and while it was fast, there was more performance to extract.
The approach: serve static shells immediately while streaming dynamic content. This combines static performance with dynamic flexibility.
What Changed: The Big Picture
The migration isn't just about updating a version number. Next.js 16 fundamentally changes how developers think about caching and rendering. Gone are the days of route-level caching switches. Now, it's all about fine-grained control with use cache directives and intelligent Suspense boundaries.
The Old Way vs. The New Way
Before, developers would set dynamic = "force-static" or revalidate = 3600 at the route level and hope for the best. The entire page was either static or dynamic—no in-between.
Now? Individual functions can be marked as cacheable, with precise cache lifetimes, and Next.js handles the rest. It's like going from painting with a roller to using a fine brush.
// The old way (Next.js 15)
export const revalidate = 3600; // Whole route cached for 1 hour
// The new way (Next.js 16)
export async function getBlogPosts() {
"use cache";
cacheTag("blog-posts");
cacheLife("max"); // Fine-grained control
return await getAllPosts(path.join(process.cwd(), "content"));
}Partial Prerendering
PPR is where things get really interesting. When a user visits the blog, they get the static shell immediately—navigation, layout, skeleton loaders, everything that doesn't change. Then, the actual blog content streams in as it becomes ready.
The result? The blog listing page went from ~400ms render time to ~24ms on subsequent requests. The first load is still fast because the shell renders instantly, and users see something meaningful right away.
Suspense Boundaries Everywhere
The key to making PPR work is wrapping dynamic content in Suspense boundaries. This becomes clear when Next.js throws errors about "uncached data accessed outside of Suspense."
// Blog post page with proper Suspense
export default function Blog({ params }: { params: Promise<{ slug: string }> }) {
return (
<section id="blog">
<Suspense fallback={<div className="h-96 animate-pulse" />}>
<BlogContent slugPromise={params} />
</Suspense>
</section>
);
}The error messages are actually helpful—they force developers to think about what should be static (the shell) versus what should stream (the content).
Cache Components: Fine-Grained Control
Cache Components provide the ability to cache exactly what's needed, for exactly how long it's needed. Blog posts? Cached with cacheLife("max") for monthly updates. Translations? Same thing—they rarely change.
Specific caches can be invalidated when needed using cacheTag() and revalidateTag(). When publishing a new blog post, call revalidateTag("blog-posts") to get fresh content without waiting for cache expiration.
export async function getPost(slug: string) {
"use cache";
cacheTag("blog-posts"); // Tag for targeted invalidation
cacheLife("max"); // Cache forever (until explicitly invalidated)
// ... fetch and process post
}Performance Results
After the upgrade:
- First load: Static shell renders instantly (0ms perceived load)
- Subsequent loads: 24-40ms for blog pages, 40-70ms for the main page
- Build time: Routes are partially prerendered, so builds are faster
- User experience: Users see content immediately, even while data streams in
This all happens automatically. Just mark what should be cached, and Next.js handles the complexity.
Conclusion
Upgrading to Next.js 16 isn't just about new features—it's about adopting a new mindset. Instead of thinking "static or dynamic?", consider "what can be cached, and what needs to be fresh?"
The result is a site that feels responsive, even when it's doing complex work behind the scenes. Users don't care about caching strategies—they want fast, responsive experiences.
Cache Components and PPR provide exactly that.