Website Performance Optimization (Part — 5): Rendering Pattern
The Evolution of Rendering
Website Performance Optimization (Part — 5): Rendering Pattern


The Evolution of Rendering
The web didn’t start with React or Next.js. It evolved through distinct eras:
- Early web (1990s): Pure HTML + CSS. Server returned a complete HTML page. Done.
- Dynamic web (2000s): PHP, JSP, Ruby on Rails — server generated HTML dynamically per request
- JavaScript era (2010s): jQuery, Backbone — JS started manipulating the DOM after load
- SPA revolution (2013+): React, Angular, Vue — JavaScript became the renderer
- Back to server (2020s): Next.js, Remix, React Server Components — hybrid approaches bringing the best of both worlds
The core tension has always been: where does rendering happen — on the client or the server?
Client Side Rendering (CSR)
How it works
Browser → GET index.html (nearly empty)
→ GET bundle.js (all your React code)
→ GET styles.css
→ JS executes → calls API → renders DOM
→ Hydration (attach event listeners)
→ User sees content ✓
The browser receives a near-empty HTML shell:
<!-- What the server actually sends -->
<html>
<body>
<div id="root"></div> <!-- empty! -->
<script src="/bundle.js"></script>
</body>
</html>
JavaScript downloads, executes, fetches data, then renders everything. The browser does all the work.
What hydration means
Rendering alone isn’t enough. After React paints the DOM, it needs to attach event listeners — click handlers, form submissions, hover effects. This process of making a rendered page interactive is called hydration.
Server sends HTML → Browser paints pixels (FCP)
→ JS downloads → JS executes
→ React attaches listeners (TTI)
→ Page is interactive ✓
The gap between FCP and TTI is the “uncanny valley” — users can see the page but buttons don’t work yet.
Performance impact
- Multiple round trips — HTML → JS → API → render
- LCP is poor — the largest content only appears after JS finishes executing and API responds
- FCP is poor — blank screen until JS loads
- SEO struggles — crawlers often see an empty
<div id="root">before JS executes
When to use CSR
- Dashboards and admin panels (SEO doesn’t matter)
- Apps behind authentication (crawlers can’t access anyway)
- Real-time tools (stock tickers, collaborative editors)
- Example: Figma, Notion, Google Docs
Server Side Rendering (SSR)
How it works
Browser → GET /products
Server → fetches data from DB/API
→ generates full HTML with data
→ sends complete HTML to browser
Browser → paints content immediately (FCP ✓)
→ downloads JS
→ hydration (make interactive)
The server does the heavy lifting before sending anything to the browser.
Next.js implementation
// pages/products.js — getServerSideProps runs on EVERY request
export async function getServerSideProps(context) {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return {
props: { products } // passed to component as props
};
}
export default function ProductsPage({ products }) {
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
What you’ll see in the Network tab: No separate API call from the browser. The HTML arrives pre-populated with data. Check Performance tab — FCP and LCP happen almost simultaneously.
Key constraints
useStateanduseEffectdon't work on the server — there's no browser, no DOM, no events- Server must respond before the browser gets anything — heavy computation = slower TTFB
- The 3-second API timeout affects the user’s page load directly
Live example: LinkedIn
Open LinkedIn → Network tab → look at the first HTML response. It arrives with your feed content already in the markup. That’s SSR. The server fetched your personalized feed before sending the response.
When to use SSR
- Personalized pages (user-specific data on every load)
- E-commerce product pages (real-time inventory, pricing)
- News feeds, social timelines
- Any page where SEO matters AND content changes per user
- Example: LinkedIn, Twitter/X, Amazon product pages
Static Site Generation (SSG)
How it works
Build time: npm run build
→ fetches ALL data from APIs
→ generates HTML for every page
→ saves as static files
Runtime: Browser → CDN serves pre-built HTML instantly
→ JS downloads → hydration
→ No API calls, no server computation
Every page is pre-rendered once at build time and served as a static file from a CDN edge node — sub-50ms globally.
Next.js implementation
// pages/blog/[slug].js
export async function getStaticProps({ params }) {
const post = await fetchBlogPost(params.slug);
return {
props: { post },
revalidate: 3600 // ISR: regenerate every hour
};
}
export async function getStaticPaths() {
const posts = await fetchAllPosts();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: false
};
}
What you’ll see in the Network tab: The HTML arrives instantly with all content. No separate API call. Check Performance — LCP and FCP fire at almost the same time because the content is already in the HTML.
The 3-second timeout difference
- SSR: A slow API = slow page load for the user
- SSG: A slow API = slow build time (only your team is waiting, not users)
Incremental Static Regeneration (ISR)
What if content changes after build? ISR solves this:
return {
props: { product },
revalidate: 60 // regenerate this page every 60 seconds
};
Serve stale content instantly from cache, regenerate in the background. Users always get fast responses.
When to use SSG
- Blog posts, documentation, marketing pages
- Product catalogues that don’t change per user
- Any content that’s the same for all visitors
- Example: Next.js docs, Vercel marketing site, company blogs
CSR vs SSR vs SSG Comparison

All the rendering patterns above can be implemented in Next.js. But which version of Next.js you use — and which router you choose — fundamentally changes how you implement them. Let’s understand the two routing systems before diving into React Server Components.
Next.js: App Router vs Page Router
Next.js is built on React and gives you two ways to structure your application. The router you choose determines everything — how you fetch data, which rendering pattern you use, how large your JS bundle is, and whether you can use streaming.
What is the Page Router?
The Page Router is the original Next.js routing system, available since Next.js 1.0. Every file you create inside the pages/ directory automatically becomes a route.
pages/
index.js → /
about.js → /about
blog/
index.js → /blog
[slug].js → /blog/:slug
api/
users.js → /api/users (API route)
How to bootstrap:
npx create-next-app@latest my-app
# Would you like to use App Router? → No
cd my-app
npm run dev
Data fetching in Page Router:
Everything runs through three special exported functions at the page level only — you cannot fetch data inside nested components.
// pages/products/[id].js
// SSR - runs on EVERY request
export async function getServerSideProps({ params }) {
const res = await fetch(`https://api.example.com/products/${params.id}`);
const product = await res.json();
return { props: { product } };
}
// SSG - runs at BUILD TIME only
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/products/${params.id}`);
const product = await res.json();
return {
props: { product },
revalidate: 3600 // ISR - regenerate every hour
};
}
// Required for dynamic SSG routes
export async function getStaticPaths() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return {
paths: products.map(p => ({ params: { id: String(p.id) } })),
fallback: false
};
}
export default function ProductPage({ product }) {
return <h1>{product.name}</h1>;
}
Layout in Page Router:
// pages/_app.js — single global layout
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
Limitations:
- Every component is a client component — all JS ships to the browser
- Data fetching only at the page level — nested components can’t fetch independently
- No native streaming — user waits for the entire page before seeing anything
- Single global layout — can’t have different layouts per section of the app
- Larger JS bundles — everything goes to the browser
What is the App Router?
The App Router was introduced in Next.js 13 and became stable in Next.js 13.4. It’s built on top of React Server Components and represents a fundamental rethinking of how Next.js applications are structured.
app/
layout.js → root layout (wraps everything)
page.js → /
about/
page.js → /about
blog/
layout.js → layout for all blog pages
page.js → /blog
[slug]/
page.js → /blog/:slug
dashboard/
layout.js → dashboard layout (e.g. sidebar)
page.js → /dashboard
api/
users/
route.js → /api/users (Route Handler)
How to bootstrap:
npx create-next-app@latest my-app
# Would you like to use App Router? → Yes
cd my-app
npm run dev
The key shift — Server Components by default:
Every component in app/ is a Server Component by default. It runs only on the server, never ships as JS to the browser. You opt into client behaviour with 'use client'.
// app/products/page.js — Server Component
// No 'use client' → runs on server, zero JS sent to browser
async function ProductsPage() {
// Fetch directly in component - no getServerSideProps needed
const res = await fetch('https://api.example.com/products', {
cache: 'no-store' // SSR - always fresh data
// cache: 'force-cache' // SSG - cache indefinitely
// next: { revalidate: 60 } // ISR - revalidate every 60s
});
const products = await res.json();
return (
<ul>
{products.map(p => (
<li key={p.id}>
<span>{p.name}</span>
<AddToCartButton productId={p.id} /> {/* client component */}
</li>
))}
</ul>
);
}
export default ProductsPage;
// app/components/AddToCartButton.js — Client Component
'use client'; // opt in to client-side rendering
import { useState } from 'react';
export default function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? 'Added ✓' : 'Add to Cart'}
</button>
);
}
Nested layouts:
// app/layout.js — root layout
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Navbar />
{children}
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.js - dashboard-specific layout
// Sidebar only shows on dashboard routes
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
);
}
Streaming with Suspense:
// app/dashboard/page.js
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<Header /> {/* renders instantly */}
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* streams in when ready */}
</Suspense>
<Suspense fallback={<RecoSkeleton />}>
<Recommendations /> {/* streams in independently */}
</Suspense>
</div>
);
}
Without streaming — user waits for the slowest component (Recommendations) before seeing anything. With streaming — Header appears instantly, Feed and Recommendations arrive as they complete, each independently.
API Route Handlers:
// app/api/users/route.js
export async function GET(request) {
const users = await db.query('SELECT * FROM users');
return Response.json(users);
}
export async function POST(request) {
const body = await request.json();
const user = await db.create(body);
return Response.json(user, { status: 201 });
}
When to use which?
- Page Router — existing large codebases that can’t migrate, teams very familiar with the old patterns, simpler apps that don’t need streaming or RSC
- App Router — all new projects, anything that needs streaming, better performance, smaller bundles, or direct DB access in components
Vercel’s official recommendation: Use App Router for all new Next.js projects.

React Server Components (RSC)
The paradigm shift
RSC is not SSR. It’s a fundamentally different mental model:
- SSR: Server renders full HTML, sends to browser, JS hydrates everything
- RSC: Some components render only on the server (never sent to browser as JS), some render on the client
Without RSC (SSR):
Server → renders everything → sends HTML + JS for all components
Browser → downloads ALL component code → hydrates ALL components
With RSC:
Server → renders server components (stays on server, zero JS sent)
→ renders client components (JS sent to browser)
Browser → downloads JS only for "use client" components
→ hydrates only client components
How it works in Next.js App Router
// app/page.js — Server Component by default (no "use client")
// Runs ONLY on server, never sent to browser as JS
async function ProductList() {
// Direct DB access — no API needed, no credentials exposed
const products = await db.query('SELECT * FROM products');
return (
<div>
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// app/components/AddToCart.js
'use client'; // This component runs on the browser
import { useState } from 'react';
export default function AddToCart({ productId }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? 'Added ✓' : 'Add to Cart'}
</button>
);
}
The key insight: ProductList never ships as JavaScript to the browser. Zero JS bytes for that component. Only AddToCart ships as JS because it needs interactivity.
Streaming — the superpower
RSC enables streaming HTML in chunks. The browser can start painting parts of the page before the full response arrives.
// app/page.js
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header /> {/* renders instantly */}
<Suspense fallback={<Skeleton />}>
<SlowFeedComponent /> {/* streams in when ready */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Recommendations /> {/* streams in independently */}
</Suspense>
</div>
);
}
Without streaming: user waits for the slowest component before seeing anything. With streaming: header appears instantly, feed and recommendations arrive as they’re ready — each independently.
RSC Benefits
- No API layer needed — server components can query databases directly
- Security — API keys, tokens, payment gateway credentials never leave the server
- Reduced bundle size — server component code is never sent to the browser
- Better initial load — less JS to download and execute
- SEO — content is in the HTML from the start
- Streaming — progressive rendering, each piece arrives as it’s ready
RSC Constraints
useState,useEffect,useContext— not available in server components- Browser APIs (
window,document,localStorage) — not available - Event handlers (
onClick,onChange) — not available - Solution: move interactive parts to
'use client'components, keep data-fetching in server components
Live example: Next.js App Router project
npx create-next-app@latest my-app
# ✓ Would you like to use App Router? → Yes
cd my-app && npm run dev
app/
layout.js → server component (wraps all pages)
page.js → server component by default
components/
Counter.js → 'use client' (needs useState)
UserProfile.js → server component (fetches from DB)
Open the Network tab — you’ll see the HTML arrives pre-populated, and the JS bundle is significantly smaller than an equivalent CSR app.
Rendering Timeline — FCP/LCP diagram

What’s next:
- Part 1 — Performance Importance & Monitoring
- Part 2 — Performance Tools (Lighthouse, DevTools, WebPageTest in depth)
- Part 3 — Network Optimization (Critical Rendering Path, HTTP/2, Caching, Compression)
- Part 4 — Build Optimization (Webpack, Vite, Tree Shaking, Code Splitting)
- Part 5 — Rendering Patterns (CSR, SSR, SSG, RSC, App Router vs Page Router)
If this article helped you think differently about web performance, share it with your team. The best performance wins happen when the entire team — not just one engineer — understands why it matters.
메타데이터
- post_id
- ee20f9b6c43b
- slug
- website-performance-optimization-part-5-rendering-pattern-ee20f9b6c43b
- url
- https://medium.com/codex/website-performance-optimization-part-5-rendering-pattern-ee20f9b6c43b
- canonical_url
- https://medium.com/codex/website-performance-optimization-part-5-rendering-pattern-ee20f9b6c43b
- author_url
- https://medium.com/@ayushv
- status
- ok
- fetched_at
- 2026-06-10 21:21:38