"Stop overthinking rendering strategies. Here's what actually matters when you're building real apps with Next.js 16, and why your choice matters more than you think."
The Rendering Problem Nobody Talks About
You've got a Next.js app. You need to render something. Sounds simple, right? Except it's not. You've got three main paths forward—Static Site Generation, Incremental Static Regeneration, and Server-Side Rendering—and picking the wrong one will haunt you later.
Here's the thing: most tutorials treat these like academic concepts. They're not. They're practical decisions that affect your infrastructure costs, your user experience, and how much sleep you'll lose debugging cache issues at 2 AM.
Static Site Generation: The Fast Lane
SSG is the speed demon. You build your pages once, at build time, and then serve the same HTML to everyone. It's the closest thing to free performance you'll ever get.
When does this actually work?
Blog posts. Documentation. Marketing pages. Anything that doesn't change every five minutes. If your content is relatively stable, SSG is your friend.
// app/blog/page.tsx
export const dynamic = 'force-static'
export const revalidate = false
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache'
})
return res.json()
}
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
The beauty here? Your server barely breaks a sweat. CDNs love this stuff. Your hosting bill stays reasonable. But there's a catch—if you update a post, nobody sees it until you rebuild. That's the trade-off.
The real advantages:
- Blazing fast response times (we're talking milliseconds)
- Your server can handle traffic spikes without flinching
- SEO loves you
- Costs stay low
The real disadvantages:
- Content updates require a rebuild
- Not great for frequently changing data
- Build times can get gnarly with lots of content
Incremental Static Regeneration: The Middle Ground
ISR is where things get interesting. You get the speed of SSG, but with the flexibility to update content without rebuilding everything. It's like having your cake and eating it too—mostly.
The idea: generate pages at build time, but regenerate them periodically or on-demand when content changes.
// app/products/page.tsx
export const dynamic = 'auto'
export const revalidate = 3600 // Revalidate every hour
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<h1>Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
)
}
For database queries, use unstable_cache with tags so you can invalidate on-demand:
// app/products/page.tsx
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
const getCachedProducts = unstable_cache(
async () => {
return await db.select().from('products').limit(10)
},
['products'],
{ revalidate: 3600, tags: ['products'] }
)
export default async function ProductsPage() {
const products = await getCachedProducts()
return (
<div>
<h1>Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
)
}
This is where you start thinking about cache invalidation. And yeah, Phil Karlton said "there are only two hard things in Computer Science: cache invalidation and naming things." He wasn't wrong.
Why ISR works:
- Fast initial load (pre-generated)
- Content updates without full rebuilds
- Flexible revalidation strategies
- Scales reasonably well
Where it gets messy:
- Slightly stale content between revalidations
- More complex setup than pure SSG
- You need to think about cache tags and invalidation
Server-Side Rendering: The Real-Time Option
SSR is the "always fresh" approach. Every request hits your server, generates the page, and sends it back. It's flexible, but it comes with a cost.
Use this when you genuinely need real-time data. User dashboards. Personalized content. Anything that changes per-request.
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'
export const revalidate = 0
async function getUserData(userId: string) {
const res = await fetch(`https://api.example.com/users/${userId}`, {
cache: 'no-store'
})
return res.json()
}
export default async function DashboardPage() {
const user = await getUserData('current-user')
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>Last updated: {new Date().toLocaleString()}</p>
</div>
)
}
Direct database queries work too:
// app/orders/page.tsx
import { db } from '@/lib/db'
async function getUserOrders(userId: string) {
return await db
.select()
.from('orders')
.where('userId', '=', userId)
}
export default async function OrdersPage() {
const orders = await getUserOrders('current-user')
return (
<div>
<h1>Your Orders</h1>
<ul>
{orders.map((order) => (
<li key={order.id}>
Order #{order.id} - ${order.total}
</li>
))}
</ul>
</div>
)
}
The upside:
- Always fresh data
- Perfect for personalized experiences
- No stale content headaches
The downside:
- Your server gets hammered
- Response times are slower
- Doesn't scale as well
- Infrastructure costs climb
Page-Level Configuration: Where the Magic Happens
Next.js 16 lets you configure rendering behavior right at the page level. This is where you actually make decisions.
For SSG pages:
// app/blog/page.tsx
export const dynamic = "force-static";
export const revalidate = false;
For ISR pages:
// app/products/page.tsx
export const dynamic = "auto";
export const revalidate = 3600;
For SSR pages:
// app/dashboard/page.tsx
export const dynamic = "force-dynamic";
export const revalidate = 0;
These exports tell Next.js exactly how to handle each page. No guessing. No magic.
Dynamic Routes: Where It Gets Real
Dynamic routes are where most people get confused. You've got a blog with thousands of posts. You can't pre-render all of them at build time—that'd take forever.
Enter generateStaticParams. It lets you specify which dynamic segments to pre-render.
SSG with Dynamic Routes
// app/blog/[slug]/page.tsx
export const dynamic = 'force-static'
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache'
})
return res.json()
}
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(
(res) => res.json()
)
return posts.map((post) => ({
slug: post.slug,
}))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
}
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
ISR with Dynamic Routes
Pre-render the popular stuff, generate the rest on-demand:
// app/products/[id]/page.tsx
export const dynamic = 'auto'
export const dynamicParams = true
export const revalidate = 3600
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600, tags: ['product'] }
})
return res.json()
}
export async function generateStaticParams() {
// Only pre-render popular products
const products = await fetch('https://api.example.com/products?popular=true').then(
(res) => res.json()
)
return products.map((product) => ({
id: product.id.toString(),
}))
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await getProduct(id)
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</div>
)
}
SSR with Dynamic Routes
When you need real-time data for every request:
// app/orders/[orderId]/page.tsx
export const dynamic = 'force-dynamic'
export const dynamicParams = true
async function getOrder(orderId: string) {
const res = await fetch(`https://api.example.com/orders/${orderId}`, {
cache: 'no-store'
})
return res.json()
}
export default async function OrderPage({ params }: { params: Promise<{ orderId: string }> }) {
const { orderId } = await params
const order = await getOrder(orderId)
return (
<div>
<h1>Order #{order.id}</h1>
<p>Status: {order.status}</p>
<p>Total: ${order.total}</p>
<p>Last updated: {new Date().toLocaleString()}</p>
</div>
)
}
Controlling Fallback Behavior
What happens when someone requests a dynamic segment that wasn't pre-rendered? You decide:
// app/blog/[slug]/page.tsx
export const dynamic = "auto";
export const dynamicParams = false; // Return 404 for unknown slugs
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((res) =>
res.json(),
);
return posts.map((post) => ({
slug: post.slug,
}));
}
Set dynamicParams = false and unknown routes get a 404. Set it to true and they're generated on-demand. Your call.
The Configuration Cheat Sheet
| Export | SSG | ISR | SSR |
|---|---|---|---|
dynamic | 'force-static' | 'auto' | 'force-dynamic' |
revalidate | false | 3600 (or number) | 0 |
dynamicParams | false | true | true |
The Trade-Off Matrix
| Feature | SSG | ISR | SSR |
|---|---|---|---|
| Speed | Fastest | Fast | Slower |
| Data Freshness | Stale | Configurable | Always Fresh |
| Server Load | Minimal | Low | High |
| Scalability | Excellent |
The Hybrid Reality
Real apps don't pick one. They use all three.
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* Static header - SSG */}
<Header />
{/* Dynamic content - SSR */}
{children}
{/* Cached sidebar - ISR */}
<Sidebar />
</body>
</html>
)
}
Your header? Static. Your main content? Depends on the page. Your sidebar? Cached and refreshed hourly. That's how you build performant apps.
The Practical Takeaway
Start with SSG. It's the fastest, cheapest option. When you need freshness, add ISR. Only reach for SSR when you genuinely need real-time data per-request.
Don't overthink it. Pick the simplest strategy that solves your problem. Your future self will thank you.
Sources:
Happy Coding! — Ahmed Fahmy