Rendering Strategies by App Type as of 2026: The Real Criteria for Choosing CSR, SSR, SSG, PPR, RSC, and Edge
To be honest, when I first started using Next.js, I mixed getServerSideProps and getStaticProps indiscriminately regardless of the page's nature — only to realize after deployment, "Why is this page so slow?" Putting off the problem with "I'll optimize it later" meant eventually having to tear apart the entire route structure, and the pain of that experience still makes me cringe. If you're reading this, you're probably in a similar situation, or you want to get your bearings before you end up there.
The options have moved well beyond the CSR·SSR·SSG triangle. With the addition of ISR's on-demand invalidation, PPR's static shell + dynamic streaming, RSC's server/client boundary, and Edge Rendering's CDN-layer processing, a hybrid that mixes strategies within a single route has effectively become the standard. It's only natural to feel overwhelmed about where to start when designing an architecture from scratch or migrating a legacy app. This post examines which strategy is right for which type of app — blogs, e-commerce, SaaS dashboards, AI apps — explaining the reasoning behind each choice and the common mistakes to avoid.
One thing to note upfront: the code examples are written for the Next.js App Router. The content site section also covers Astro, but the rest assumes Next.js — so if you're using a different framework, treat these as conceptual reference.
Core Concepts
Rendering Strategies at a Glance
Rendering architecture ultimately comes down to "where and when HTML is generated." Let's start with the four classic strategies.
| Strategy | When HTML is Generated | Where | Notes |
|---|---|---|---|
| CSR | After JS executes in the browser | Client | Bundle size determines performance |
| SSR | On every request | Server | TTFB is proportional to server processing time |
| SSG | At build time | Server (pre-generated) | Can be served instantly via CDN |
| ISR | At build time + regeneration | Server | Periodic refresh + event-based invalidation |
Two more additions have become established between 2025 and 2026.
PPR (Partial Prerendering): Generates a static shell (layout, navigation, top-level regions) at build time, and fills in only the dynamic content via React Suspense streaming. It resolves the binary choice between "fully static" and "fully dynamic" within a single route. In Next.js 15, you need to add
experimental: { ppr: true }tonext.config.js; from Next.js 16 onward, it works by default without a separate flag.
Edge Rendering: Rendering and API processing are performed at CDN edge nodes such as Cloudflare Workers and Vercel Edge Functions. It achieves global low latency without a dedicated server infrastructure, but some Node.js runtime APIs are unsupported, and the 128MB memory limit and cold-start issues come up frequently in practice.
ISR: Knowing Only Time-Based Regeneration Is Only Half the Picture
If you think ISR is "done" once you set a revalidate time, you're stuck in the early 2020s. Event-driven on-demand invalidation using revalidateTag() is already common practice. When inventory changes or a CMS post is published, you invalidate a specific cache immediately rather than waiting for the time interval.
// Invalidate tags from the inventory update API
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const { productId } = await req.json();
revalidateTag(`product-${productId}`);
return Response.json({ revalidated: true });
}What Changed as of 2026
FID has been fully replaced by INP (Interaction to Next Paint) in Core Web Vitals.
INP (Interaction to Next Paint): The time from a user interaction (click, keystroke, etc.) to the next screen update. 200ms or below is the "good" threshold; according to the 2025 Web Almanac, only 48% of pages on mobile pass CWV.
TTFB (Time to First Byte): The time from when the browser sends a request to when it receives the first byte from the server. SSR is longer than SSG/CDN because server processing time is added.
RSC (React Server Components) is no longer an experimental feature. Components that run only on the server are completely excluded from the client bundle, and there's a growing body of cases where migrating data-fetching logic to RSC dramatically improves initial render performance. In our own project, moving dashboard data fetching to the server led to a noticeably smaller bundle size.
Practical Application
Content Sites: Blogs, Docs, Landing Pages
If your content doesn't change frequently and SEO matters, SSG is the most natural starting point. Astro defaults to zero JS output and supports Island Architecture — you can achieve both performance and interactivity by "island-inserting" React, Vue, or Svelte components only where needed on top of static HTML.
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import CommentSection from '../../components/CommentSection.tsx';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<Layout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<Content />
<!-- Only the comment section is hydrated as a client component -->
<CommentSection client:load postId={post.slug} />
</article>
</Layout>Only components marked with client:load are hydrated with JavaScript. The rest of the page is pure HTML. The interactive comment feature operates only within that island, and the article body itself renders without JS.
| Metric | Astro SSG | Next.js SSG (optimized) |
|---|---|---|
| Default JS bundle | 0 KB (outside components) | Includes framework runtime |
| LCP | Very low | Low |
| Island support | Native | Approximate via RSC |
| SEO suitability | ★★★★★ | ★★★★★ |
E-commerce: Product Listings, Detail Pages, Checkout
E-commerce is a classic case where multiple strategies must coexist within a single service. I've often seen teams try to apply the same strategy to product listings, detail pages, and checkout — and end up with a half-baked result on both ends. Honestly, I've done it too.
Product listings tend to be important for SEO, and in many cases updating price and inventory on an hourly basis is sufficient — making ISR a natural fit. Pairing it with on-demand invalidation makes it even more robust.
// app/products/page.tsx — Caching product listings with ISR
export const revalidate = 3600; // Regenerate every 1 hour
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }, // Can be immediately invalidated with revalidateTag('products')
}).then(r => r.json());
return <ProductGrid products={products} />;
}Checkout, on the other hand, is entangled with per-user cart state and authentication, making caching impossible. SSR is the right answer.
// app/checkout/page.tsx — Handling cart and auth state in real time with SSR
import { getServerSession } from 'next-auth'; // next-auth or auth.js
import { getCart } from '@/lib/cart'; // Custom cart utility
export const dynamic = 'force-dynamic';
export default async function CheckoutPage() {
const session = await getServerSession();
const cart = await getCart(session.userId);
return <CheckoutForm cart={cart} />;
}Product detail pages fall somewhere in between. Product names, descriptions, and images rarely change, but real-time inventory can vary with every request. PPR was made precisely for this situation. Deliver the product information first as a static shell, and stream only the inventory data inside a Suspense boundary.
// app/products/[id]/page.tsx — PPR: static shell, dynamic inventory only
import { Suspense } from 'react';
import StockStatus from './StockStatus';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id); // Included in the static shell
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* Only real-time inventory is streamed via Suspense */}
<Suspense fallback={<span>Checking inventory...</span>}>
<StockStatus productId={params.id} />
</Suspense>
</div>
);
}For StockStatus to actually behave as a dynamic region, you must explicitly opt out of caching inside the component. Omitting this step means PPR's dynamic streaming won't work as intended.
// app/products/[id]/StockStatus.tsx
import { unstable_noStore as noStore } from 'next/cache';
export default async function StockStatus({ productId }: { productId: string }) {
noStore(); // The key to making this component a dynamic region in PPR
const { available, count } = await fetch(
`/api/stock/${productId}`,
{ cache: 'no-store' }
).then(r => r.json());
return (
<span className={available ? 'text-green-600' : 'text-red-500'}>
{available ? `${count} in stock` : 'Out of stock'}
</span>
);
}| Page | Strategy | Reason |
|---|---|---|
| Product listings | ISR (1hr + on-demand) | Needs SEO; periodic refresh is sufficient for price/inventory |
| Product detail | PPR | Product info is static; only real-time inventory is dynamic |
| Cart / Checkout | SSR | Per-user state; not cacheable |
| Personalized recommendations | Edge Rendering | Regional pricing, A/B testing handled without a server |
SaaS Dashboard: The Boundary Between RSC and CSR
Dashboards involve long sessions after login with no SEO requirement, so CSR (SPA) is still a perfectly rational choice. But when there are many DB queries and complex charts involved, moving data-fetching logic to the server with RSC offers advantages in both bundle size and client-side computation.
The most confusing part when first using RSC is the server–client boundary. Server components can query the DB directly, but code that manipulates the DOM — like chart libraries — requires client components. The only way to connect the two is to serialize data as JSON and pass it down as props, because functions and class instances that can't be represented as JSON cannot cross the server-client boundary.
// app/dashboard/analytics/page.tsx — RSC: DB queries handled on the server
import { db } from '@/lib/db';
import AnalyticsChart from './AnalyticsChart';
export default async function AnalyticsPage() {
// This file itself is not included in the client bundle — DB logic lives only on the server
const stats = await db.query<{ date: string; revenue: number; users: number }[]>(`
SELECT date, revenue, users
FROM daily_stats
WHERE date >= NOW() - INTERVAL '30 days'
`);
// Only JSON-serializable data is passed to the client component
return <AnalyticsChart data={stats} />;
}// app/dashboard/analytics/AnalyticsChart.tsx — Client component
'use client';
import { LineChart, Line, XAxis, YAxis } from 'recharts';
interface StatRow {
date: string;
revenue: number;
users: number;
}
export default function AnalyticsChart({ data }: { data: StatRow[] }) {
// recharts requires DOM access, so 'use client' is mandatory
return (
<LineChart width={600} height={300} data={data}>
<XAxis dataKey="date" />
<YAxis />
<Line type="monotone" dataKey="revenue" stroke="#8884d8" />
</LineChart>
);
}The core RSC principle: Server components can receive client components as
children, but client components cannot import server components. Attaching'use client'only to leaf components where interaction is actually needed, and keeping data fetching in server components, is the key to bundle optimization.
Interactive AI Apps: Streaming SSR
I still remember the first time I integrated the GPT API. If you've ever experienced it, you know how frustrating it is when the screen freezes completely for 3–5 seconds before the first token appears — from the user's perspective, they can't even tell whether the request is being processed or has failed. Instead of making users wait until the entire response is complete, streaming tokens to the browser the moment they're generated means the very first character on screen already signals "a response is coming."
// app/api/chat/route.ts — Streaming API route
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai('gpt-4o'),
messages,
});
return result.toDataStreamResponse();
}Emitting a stream from the server isn't enough on its own. You need a client component that consumes it in real time. The useChat hook from the Vercel AI SDK handles receiving streaming responses chunk by chunk and rendering them progressively on screen.
// app/chat/AIResponseStream.tsx — Streaming consumer client component
'use client';
import { useChat } from 'ai/react'; // Vercel AI SDK
export default function AIResponseStream() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat',
});
return (
<div>
<div className="messages">
{messages.map(m => (
<div key={m.id} className={m.role === 'user' ? 'user' : 'assistant'}>
{m.content}
</div>
))}
{isLoading && <div className="assistant typing">▌</div>}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}// app/chat/page.tsx — Setting a streaming boundary with Suspense
import { Suspense } from 'react';
import AIResponseStream from './AIResponseStream';
export default function ChatPage() {
return (
<Suspense fallback={<div>Connecting to AI...</div>}>
<AIResponseStream />
</Suspense>
);
}Edge Rendering: Global Personalization Without a Server
For cases where the response varies based on where the user is connecting from — regional pricing displays or A/B testing — a round-trip to a central server adds serious latency. Edge Middleware handles this processing directly at the CDN layer.
// middleware.ts — Vercel Edge Middleware: injecting geo information
import { NextRequest, NextResponse } from 'next/server';
export const config = { matcher: '/products/:path*' };
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? 'KR';
const currency = country === 'US' ? 'USD' : 'KRW';
const response = NextResponse.next();
// Passing geo info via headers so RSC or API Routes can read and use it
response.headers.set('x-user-country', country);
response.headers.set('x-user-currency', currency);
return response;
}One important caveat: the Edge Runtime does not provide the full Node.js environment. Libraries that depend on fs, native modules, or Node.js-specific crypto APIs cannot run on the Edge. The 128MB memory limit means you should avoid patterns that load large amounts of data into memory, and on routes with intermittent traffic, cold starts can result in higher first-request latency than expected. Verifying Edge Runtime compatibility for the packages you intend to use before adopting Edge will save you a lot of trial and error in practice.
Pros and Cons Analysis
When I first made the table below, I tried to express everything with star ratings — then realized it varies too much by situation. SSR's LCP in particular can match SSG depending on server response speed and caching strategy, or lag behind significantly. Treat the table below as a guide to general tendencies.
| Strategy | Key Advantages | Notable Drawbacks | Mitigation | Best Fit |
|---|---|---|---|---|
| SSG | Best LCP, lowest server cost, instant CDN serving | Hundreds of thousands of pages → build time explodes | Switch to ISR or on-demand ISR | Blogs, docs, marketing landing pages |
| ISR | SSG performance + periodic freshness | Cache invalidation timing is hard to predict | Pair with event-based invalidation via revalidateTag() |
News, e-commerce product listings |
| SSR | Always fresh data, strong SEO | Higher TTFB, higher server cost | Move to Edge or add a caching layer | Personalized content, authenticated areas |
| CSR | Rich interactivity, lowest server cost | Poor SEO, slow initial JS load | Avoid for public-facing pages | Dashboards, admin panels, post-login areas |
| PPR | Static LCP + dynamic updates simultaneously | Next.js-specific (high vendor lock-in) | Self-hosting without Vercel is possible | E-commerce detail pages, news portals |
| Edge | Global low latency, personalization processing | Node.js API limits, 128MB memory, cold starts | Pre-validate Edge Runtime compatibility | Regional pricing, A/B testing |
Personally, ISR cache invalidation timing was the trickiest part in e-commerce projects. I had revalidate = 3600 set, and when a price changed, users were still seeing the old price. Since then, whenever I use ISR I always make it a habit to configure revalidateTag() alongside it.
The Most Common Mistakes in Practice
-
Forcing SSR onto admin panels that don't need SEO. Post-login areas have no search visibility and data changes in real time — CSR or an RSC combination is more appropriate. Using SSR just increases server costs with no improvement to user experience.
-
Setting ISR's
revalidateand forgetting on-demand invalidation. Settingrevalidate = 86400(24 hours) for frequently changing data like inventory or pricing means showing users stale information. It's strongly recommended to configurerevalidateTag()alongside it. -
Drawing the RSC/client component boundary too high up the tree. Attaching
'use client'to a root or high-level component includes the entire subtree below it in the client bundle. Attaching it only to the leaf components that actually need interaction, and keeping data fetching in server components, is better for both bundle size and performance.
Closing Thoughts
Rendering strategy in 2026 is not about picking one approach for the entire app — it's about composing the right strategy for each route based on its characteristics: SEO needs, data change frequency, authentication requirements, and build scale.
Here are three steps you can take right now.
-
Start by measuring your current state. Check the LCP and INP of your key pages in PageSpeed Insights and record the numbers. Without a baseline before changing strategy, you have no way to compare whether things improved.
-
Classify your routes by type and adjust the strategy. Organizing your routes in a spreadsheet around "Is this page public? Does it need SEO? How often does the data change?" makes the right strategy become clear. In Next.js App Router, you can change the strategy per route simply by adjusting the values of
export const revalidateandexport const dynamic. -
Measure again after the strategy change and compare. Comparing against the numbers you recorded in step 1 lets you directly observe which choices actually impact real user experience. This cycle itself is what builds architectural intuition.
References
For Beginners
- CSR vs SSR vs SSG vs ISR: Best Rendering Method in 2026
- Rendering Patterns in Next.js: CSR, SSR, SSG, ISR, RSC, PPR, and DPR
- Next.js Partial Prerendering Docs
- The Complete Guide to Frontend Architecture Patterns in 2026
For Deeper Dives
- Partial Prerendering (PPR) in Production: Architecture Patterns (2026 Edition)
- Partial Prerendering Guide (2026) - PageGlass
- React Server Components in 2026: Patterns, Pitfalls, and When to Actually Use Them
- Edge Computing Frontend 2026 - Serverless Edge Functions Guide
- Core Web Vitals 2026: New Thresholds and Tuning
- SSR vs CSR vs SSG vs ISR: The 2026 Frontend Tradeoff
- Next.js vs Remix vs Astro vs SvelteKit in 2026: The Definitive Framework Decision Guide