Server-side rendering in Next.js 16 is no longer a binary choice between SSR and SSG. React Server Components, streaming, and Cache Components give you granular control over what renders where and when.
The short answer: use dynamic rendering for personalized content (dashboards, user data) and static/cached rendering for public content (marketing pages, docs, blog posts). Most pages mix both patterns within the same route.
Server-side rendering (SSR) means rendering HTML on the server for each request. In Next.js 16, this happens automatically when Server Components access request-specific data like cookies, headers, or uncached database queries. You don't configure it explicitly anymore; the framework infers it from your code.
This shift confused a lot of developers when the App Router launched, and honestly, it still trips people up. The old model was explicit: you chose getServerSideProps for SSR or getStaticProps for SSG, and that was that. The new model requires understanding how data flows through your component tree. Once it clicks, it's more powerful, but there's a learning curve.
Updated January 2026 for Next.js 16.1 and React 19.2.
The Rendering Landscape Has Changed
If you learned Next.js with the Pages Router, you need to unlearn some things. The concepts of getServerSideProps and getStaticProps don't exist in the App Router. React Server Components fundamentally changed how rendering works, and the mental model is different enough that trying to map old concepts onto new ones will confuse you.
Here's a quick reference for how the old patterns translate:
| Concept | Pages Router (old) | App Router (current) |
|---|---|---|
| SSR | getServerSideProps | Dynamic Server Components |
| SSG | getStaticProps + getStaticPaths | Static Server Components + generateStaticParams |
| ISR | revalidate option | revalidate export or revalidateTag() |
| Hybrid | Not possible per-component | Mix static and dynamic in same route |
The most important shift is in the last row. In the Pages Router, a page was either SSR or SSG. You couldn't have a static header with a dynamic user widget on the same page without client-side fetching. Now you can. A single route can have static navigation, cached content sections, and dynamic user data, each rendering with its own strategy.
This granularity is powerful, but it means you need to think about rendering at the component level, not the page level. When you read cookies in a component, you're not just affecting that component; you're potentially affecting the entire route's rendering strategy. Understanding these boundaries is the key to getting good performance out of the App Router.
Server Components Are Server-Rendered by Default
In Next.js 16, every component in the app directory is a Server Component unless you explicitly add 'use client'. This is the opposite of how most React developers learned to think about components. In the old world, everything ran in the browser by default, and you had to do extra work to run code on the server. Now the server is the default.
Server Components run on the server and send HTML to the browser. They don't ship JavaScript for their logic. They can't use browser APIs, event handlers, or React hooks like useState or useEffect. What they can do is access your database directly, read environment variables, and perform any server-side operation you need.
// This is a Server Component by defaultasync function DashboardStats({ userId }: { userId: string }) { const stats = await db.stats.findUnique({ where: { userId } }); return ( <div> <p>Projects: {stats.projectCount}</p> <p>Storage: {stats.storageUsed}GB</p> </div> );}Notice the async keyword. Server Components can be async functions, which means you can await data fetching directly in the component body. No useEffect, no loading states to manage manually, no prop drilling from a parent that fetched the data. The component just fetches what it needs and renders.
But here's where it gets interesting: this isn't SSR in the traditional sense. The component runs on the server, but Next.js decides when to run it:
- At build time (static) if there are no dynamic data sources
- At request time (dynamic) if the component reads cookies, headers, search params, or uncached data
You don't choose SSR vs SSG explicitly. The framework infers it from your data access patterns. This is elegant once you understand it, but it means you need to be aware of what makes a component "dynamic."
When Rendering Becomes Dynamic
Next.js switches to dynamic (request-time) rendering when your Server Component touches anything that could be different between requests. The three main triggers are:
Reading request-specific data: Calling
cookies(),headers(), or accessingsearchParamssignals that your component needs information that only exists at request time. Next.js can't possibly know what cookies a user will have at build time, so it has to wait for the actual request.Fetching uncached data: If you use
fetchwithcache: 'no-store'or make database queries without caching, Next.js assumes the data might be different on every request.Explicitly opting in: The
connection()function tells Next.js you need dynamic rendering even if it can't detect why. This is useful when you're doing something the framework doesn't understand.
import { cookies } from 'next/headers';async function UserGreeting() { // Reading cookies makes this dynamic const cookieStore = await cookies(); const sessionToken = cookieStore.get('session'); if (!sessionToken) { return <p>Welcome, guest</p>; } const user = await getUser(sessionToken.value); return <p>Welcome back, {user.name}</p>;}The moment you call cookies(), the entire route becomes dynamic. This is the part that surprises people. You might think only the UserGreeting component would be affected, but Next.js renders the whole route together. If any component in the tree needs dynamic rendering, the whole page renders dynamically.
For SaaS dashboards, this is usually fine. Most dashboard pages depend on authenticated user context anyway, so they're inherently dynamic. Dynamic rendering with streaming is fast enough for most use cases. When building MakerKit, we measured static marketing pages loading in roughly 80-120ms (edge-cached) vs 250-400ms for dynamic dashboard pages with database queries. The difference matters for SEO-critical pages but is barely noticeable for authenticated app routes where users expect a brief load.
When to Force Static Rendering
Static pages are cheaper to serve and faster for users because there's no server roundtrip. The HTML is generated once at build time (or when revalidated) and served from a CDN. For content that's the same for every visitor, this is the obvious choice.
The classic candidates for static rendering are marketing pages, documentation, blog posts, and public listings. These pages don't change based on who's viewing them. A pricing page shows the same prices to everyone. A blog post has the same content regardless of whether the reader is logged in.
The challenge comes when you want a mostly-static page with a small dynamic element. The most common example is showing a "Sign in" or "Dashboard" link in the navigation based on whether the user is authenticated. If you check authentication in a Server Component, you've made the whole page dynamic.
The solution is to isolate dynamic elements in Client Components. Client Components run in the browser after the initial HTML loads, so they don't affect the page's static status. The page renders statically, and then the client-side JavaScript handles the dynamic parts.
// app/pricing/page.tsx - Static pageimport { PricingTiers } from './pricing-tiers';import { AuthStatus } from './auth-status';export default function PricingPage() { return ( <div> <nav> {/* Client Component - doesn't affect page's static status */} <AuthStatus /> </nav> {/* Server Component - renders at build time */} <PricingTiers /> </div> );}// app/pricing/auth-status.tsx'use client';import { useEffect, useState } from 'react';export function AuthStatus() { const [user, setUser] = useState<{ name: string } | null>(null); useEffect(() => { // Check auth client-side to keep page static fetch('/api/me').then(r => r.json()).then(setUser); }, []); if (!user) return <a href="/auth/sign-in">Sign in</a>; return <a href="/home">Dashboard</a>;}The page renders statically and ships to the CDN. When a user loads it, they get the static HTML instantly. Then React hydrates, the Client Component mounts, and it fetches the auth status. There's a brief flash where the auth state is unknown, but for a marketing page, that's an acceptable tradeoff for the performance benefits of static rendering.
This pattern comes up constantly in SaaS applications. Your marketing site should be fast and SEO-friendly, but you still want logged-in users to see a path back to their dashboard. Isolating the dynamic check to a Client Component gives you both.
Cache Components: The Best of Both Worlds
Cache Components (what Vercel previously called PPR, or Partial Prerendering) solve a problem that's plagued web development for years: what do you do when a page is mostly static but has some personalized content?
Before Cache Components, you had two imperfect options. You could make the whole page dynamic and accept slower load times. Or you could make the page static and fetch personalized content client-side, accepting a content flash. Neither was great.
Cache Components let you serve a static shell immediately while streaming dynamic content. The static parts cache at the edge and load instantly. The dynamic parts render on the server when the request comes in and stream to the browser as they complete. The user sees the page structure right away, and the personalized bits fill in smoothly.
This is ideal for pages that are mostly static with some personalized sections. Think of an e-commerce product page: the product details, images, and description are the same for everyone, but the "Recommended for you" section depends on the user's browsing history. With Cache Components, the product details load from cache while recommendations stream in.
import { Suspense } from 'react';import { ProductDetails } from './product-details';import { UserRecommendations } from './user-recommendations';export default function ProductPage({ params }: { params: { id: string } }) { return ( <div> {/* Static - cached and served immediately */} <ProductDetails productId={params.id} /> {/* Dynamic - streams in after static content */} <Suspense fallback={<RecommendationsSkeleton />}> <UserRecommendations /> </Suspense> </div> );}The Suspense boundary is doing the heavy lifting here. It tells Next.js that UserRecommendations can render independently and stream in when ready. The ProductDetails component renders statically because it doesn't access any request-specific data.
Cache Components are approaching production readiness in Next.js 16. The API has stabilized, and Vercel's infrastructure supports it well. If you're deploying to Vercel and have pages that fit this pattern, it's worth experimenting with. Check the Next.js 16 features breakdown for the latest status and configuration details.
The use cache Directive
Next.js 15 relied heavily on implicit caching. The framework tried to guess what should be cached based on how you fetched data. This worked sometimes, but it also led to confusing behavior where pages would be cached when you didn't expect it, or wouldn't be cached when you thought they should be.
Next.js 16 introduced explicit caching with the use cache directive. Instead of relying on framework heuristics, you declare what should be cached. This is more verbose but far more predictable.
async function PopularProducts() { "use cache" const products = await db.products.findMany({ orderBy: { salesCount: 'desc' }, take: 10, }); return <ProductGrid products={products} />;}Adding "use cache" at the top of a component tells Next.js to cache its output. The first time this component renders, it fetches from the database and caches the result. Subsequent requests get the cached version.
For more control, you can combine use cache with cacheLife and cacheTag. cacheLife sets how long the cache lives. cacheTag lets you invalidate specific cached items when their underlying data changes.
import { cacheLife, cacheTag } from 'next/cache';async function ProductDetails({ productId }: { productId: string }) { "use cache" cacheLife('hours'); // Cache for 1 hour cacheTag(`product-${productId}`); const product = await db.products.findUnique({ where: { id: productId } }); return <div>{product.name}</div>;}// Invalidate when product updatesasync function updateProduct(productId: string, data: ProductData) { await db.products.update({ where: { id: productId }, data }); revalidateTag(`product-${productId}`);}The cacheTag creates a named cache entry. When you call revalidateTag with the same tag, Next.js invalidates that cached content. This is powerful for SaaS applications where you know exactly when data changes. Update a product? Invalidate its cache. Publish a blog post? Invalidate the blog listing cache.
For SaaS specifically, use cache shines for shared data that doesn't change per-user. Leaderboards, aggregated statistics, pricing tiers, feature flags, and CMS content are all good candidates. The data is expensive to compute but the same for everyone, so caching it makes sense.
ISR: Still Useful for Content Sites
Incremental Static Regeneration predates the App Router, but it still has a place. ISR lets you set a time-based revalidation schedule for static pages. The page generates statically, serves from cache, and regenerates in the background after a specified interval.
// app/blog/[slug]/page.tsxexport const revalidate = 3600; // Regenerate every hourexport async function generateStaticParams() { const posts = await getAllPosts(); return posts.map((post) => ({ slug: post.slug }));}export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await getPost(params.slug); return <Article content={post.content} />;}The revalidate = 3600 export tells Next.js to regenerate this page at most once per hour. Users always get a cached version, which keeps load times fast. But the content stays reasonably fresh because it regenerates periodically.
ISR works well for content that updates on a schedule rather than in response to specific events. Blogs are the classic example. You publish a post, and within an hour (or whatever interval you set), the static page updates. For most blogs, that delay is fine. Readers don't need to see posts the instant they're published.
The same applies to documentation, changelog pages, or any content synced from an external source. If you're pulling data from a CMS or external API on a schedule, ISR matches that pattern naturally.
The tradeoff is infrastructure complexity. Vercel handles ISR natively, and it just works. Self-hosted deployments need additional setup: you need somewhere to store the cached pages and a way to trigger regeneration. It's doable with Redis or a CDN, but it's more work than just deploying to Vercel. See our best hosting for Next.js guide if you're evaluating options.
Decision Framework: Choosing Your Rendering Strategy
After working with these patterns across dozens of MakerKit deployments, here's how I think about rendering decisions.
Static rendering is the default for anything public-facing where SEO matters. Marketing pages, blog posts, documentation, pricing pages. If the content is the same for every visitor and you want search engines to index it well, static is the obvious choice. It's also the cheapest to serve since you're just delivering files from a CDN.
Dynamic rendering is inevitable for authenticated experiences. The moment you need to know who the user is, you're in dynamic territory. Dashboards, settings pages, account management, anything behind a login wall. Don't fight it. Dynamic rendering with streaming is plenty fast for these use cases. Focus your optimization energy elsewhere.
Cache Components bridge the gap for pages that are mostly public but have personalized elements. Product pages with user-specific recommendations. Marketing pages that show different CTAs to logged-in vs logged-out users. Landing pages that display the user's company name if they're signed in. If you find yourself wanting to make a page static but needing one dynamic section, Cache Components are your answer.
The use cache directive is for surgical caching within dynamic routes. Your dashboard is dynamic because it needs user context, but maybe the sidebar navigation or some aggregated company stats could be cached. use cache lets you cache those specific components without affecting the rest of the page.
ISR fills a niche for time-based freshness. If your content updates on a schedule and you can tolerate some staleness, ISR is simpler than setting up webhooks and on-demand revalidation. It's particularly good for content pulled from external CMSs where you don't control when updates happen.
If you're unsure which to use, start with Server Components and let Next.js decide. Add explicit caching only when you see performance issues or have specific freshness requirements. The defaults are sensible for most applications, and premature optimization of rendering strategies is a real time sink.
Practical Example: SaaS Application Structure
In a typical SaaS built with MakerKit, different parts of the app use different strategies. Here's how the routing structure maps to rendering patterns:
app/├── (marketing)/ # Static - SSG at build time│ ├── page.tsx # Homepage│ ├── pricing/│ └── blog/├── (auth)/ # Dynamic - redirects based on session│ ├── sign-in/│ └── sign-up/└── home/ ├── [account]/ # Dynamic - team context from URL │ ├── dashboard/ # Dynamic with streaming for widgets │ └── settings/ └── (user)/ # Dynamic - personal accountThe (marketing) group contains all public-facing pages. These are fully static. They don't read cookies or check authentication in Server Components. Any auth-dependent UI (like showing "Dashboard" instead of "Sign in" in the nav) happens in Client Components that fetch auth state after hydration.
The (auth) group handles sign-in and sign-up flows. These pages are dynamic because they need to redirect already-authenticated users. When you load /sign-in while logged in, the server checks your session and redirects you to the dashboard. That requires dynamic rendering.
The home/ directory is the authenticated application. Everything here is dynamic by necessity. The [account] segment loads team context from the URL, which means reading route parameters and fetching team data. Dashboard pages use Suspense boundaries to stream non-critical widgets so the core UI loads fast while secondary data fills in.
The key insight is pushing dynamic boundaries as deep as possible. The root layout for (marketing) doesn't read cookies. The root layout for home/ does, but only because everything under it needs authentication anyway. You don't want a shared layout reading cookies if only one child route needs that information, because it would make all child routes dynamic.
Common Mistakes
The most common mistake I see is accidentally making pages dynamic. A developer adds a quick auth check to a shared layout, not realizing it makes every page using that layout dynamic. Or they read searchParams in a layout because it seemed convenient, and now their entire marketing site is server-rendered.
The fix is awareness. Know what triggers dynamic rendering: cookies(), headers(), searchParams, and uncached data fetches. When you add one of these to your code, think about where in the component tree it lives and what pages it affects.
Reading searchParams in layouts deserves special mention because it's so tempting and so problematic. You might want the layout to highlight the current nav item based on a query parameter, or persist some filter state. But if your layout reads searchParams, every page using that layout becomes dynamic. Move search param handling to individual pages, or use a Client Component that reads the URL client-side.
Over-caching dynamic data is the opposite problem. Developers hear "caching is fast" and start caching everything. But caching user-specific data can leak information between users. If your cached component receives a userId prop, something is wrong. That cache entry is specific to one user, and you're either wasting cache space or risking data leaks. Only cache shared, public data.
Ignoring streaming leaves performance on the table. If your dynamic page has slow data fetches, wrap them in Suspense. Users see content faster because the page doesn't block on one slow query. We've seen dashboard load times drop by 60% just by wrapping non-critical widgets in Suspense boundaries. The main content loads immediately; the analytics charts or activity feeds stream in a moment later.
Using Client Components for everything is a habit from the Create React App days. When everything ran in the browser, you didn't think about where code executed. In Next.js, adding 'use client' everywhere defeats the purpose of Server Components. You ship more JavaScript, lose the ability to access server resources directly, and can't use async components. Only add 'use client' when you need browser APIs, event handlers, or React hooks.
Nesting dynamic components inside cached ones is subtle. If a cached component renders a child that reads cookies, the cache becomes invalid. The entire component tree under use cache must be cacheable. This trips people up because the error isn't obvious. Your cache just doesn't work, and you have to trace through the component tree to find the dynamic access.
Using fetch without explicit cache options leads to unpredictable behavior. Default caching behavior has changed across Next.js versions, and different deployment platforms handle it differently. Be explicit: use cache: 'force-cache' for static, cache: 'no-store' for dynamic, or next: { revalidate: 3600 } for time-based. Explicit is better than implicit when it comes to caching.
Frequently Asked Questions
Is SSR slower than SSG in Next.js?
Should I use ISR or on-demand revalidation?
How do Cache Components differ from ISR?
Can I mix static and dynamic content on the same page?
What rendering strategy is best for SaaS dashboards?
Does self-hosting affect rendering strategies?
Conclusion
Next.js 16 moved rendering decisions from page-level configuration to component-level data access patterns. The framework infers whether to render statically or dynamically based on what your components do. This is more powerful than the old explicit model, but it requires understanding how your code affects rendering.
For most SaaS applications, the split is straightforward: static for marketing, dynamic for dashboards, and Cache Components for pages that need both. The use cache directive gives you explicit control when the defaults don't fit.
The best advice I can give is to not overthink it. Start with Server Components everywhere, add 'use client' when you need browser APIs, and use Suspense for slow data fetches. Monitor your Core Web Vitals and actual user performance. Optimize the rendering strategy when you see problems, not preemptively.
Learn More
Ready to build a SaaS with these patterns already implemented? Check out the free MakerKit course to see how authentication, Server Actions, and multi-tenancy work together. Or explore why Next.js remains the top choice for SaaS in 2026.