The server sends a minimal HTML shell. The browser downloads JavaScript, runs it, fetches data, and renders the UI - all on the client.
A React app with useEffect fetching:
sequenceDiagram
participant B as Browser
participant S as Server
participant A as API
B->>S: GET /products page
S->>B: empty HTML + JS bundle
B->>B: executes JS
B->>A: fetch data
A->>B: JSON
B->>B: renders UI
Trade-offs:
| Pros | Cons |
|---|---|
| Simple to build and host (just static files) | Slow to first content (blank page while JS loads) |
| Rich interactivity | Poor SEO - crawlers may not run JavaScript |
| No server needed | Data fetching waterfalls |
The server renders the full HTML on every request - including the data and sends it to the browser. React then "hydrates" the page so it becomes interactive.
In Next.js: Any async Server Component that opts out of caching (cache: 'no-store') is SSR by default.
sequenceDiagram
participant B as Browser
participant S as Server
participant A as API
B->>S: GET /products page
S->>A: fetch data
A->>S: JSON
S->>B: full HTML with content
B->>B: hydrates (attaches JS)
Trade-offs:
| Pros | Cons |
|---|---|
| Fast first paint - real HTML arrives immediately | Requires a server (more infrastructure) |
| Great SEO - crawlers see full content | Every request hits the server |
| No client-side data waterfalls | Slower time-to-interactive than fully static |
Pages are pre-rendered at build time and served as static HTML. Identical to CSR from an infrastructure standpoint (just files), but the HTML already contains the content.
In Next.js: This is the default for pages that don't opt out of caching. Next.js pre-renders them at build time and caches the result.
sequenceDiagram
participant S as Server
participant C as CDN
participant B as Browser
Note over S: build time
S->>C: pre-rendered HTML
B->>C: GET /products page
C->>B: cached HTML
Trade-offs:
| Pros | Cons |
|---|---|
| Extremely fast (served from CDN edge) | Content only updates when you redeploy or revalidate |
| Great SEO | Not suitable for user-specific or frequently-changing data |
| No server computation per request |
Bonus: Incremental Static Regeneration (ISR)
You can also use SSG with a hybrid approach: serve a cached static page, but regenerate it in the background after a time interval.
// Regenerate this page at most once per hour
const data = await fetch('<https://api.example.com/posts>', {
next: { revalidate: 3600 }
})
In practice, Next.js lets you mix strategies per route - even per component within a page.
| Content type | Strategy | Example |
|---|---|---|
| Marketing homepage, about page, blog posts | SSG | Fetches at build time, fast and indexable |
| Product listings with inventory counts | SSR or ISR | Data changes, needs to be fresh |
| User dashboard, shopping cart | CSR (Client Component) | User-specific, no SEO needed |
| News feed, live scores | SSR with cache: 'no-store' |
Always fresh |
| Mostly static, refreshes daily | ISR (revalidate: 86400) |
Best of both - fast CDN + auto-refresh |
<aside> ⌨️
Hands on: Run npm run build in your project. In the terminal output, find the table showing your routes. Identify which routes are marked ○ (Static) and which are λ (Dynamic). Add { cache: 'no-store' } to a fetch in one of your pages and rebuild - that route should flip from static to dynamic.
</aside>
generateStaticParamsDynamic routes like /blog/[slug] are server-rendered by default because Next.js doesn't know all the possible slug values at build time. If the set of values is known (e.g., all blog posts in a CMS), you can export generateStaticParams to pre-render them as static HTML at build time:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('<https://api.example.com/posts>').then(r => r.json())
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}))
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await fetch(`https://api.example.com/posts/${slug}`).then(r => r.json())
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
)
}
At build time, Next.js calls generateStaticParams, gets the list of slugs, and pre-renders an HTML file for each one. Requests to /blog/my-first-post are served instantly from the CDN - no server computation needed per request.
Search engines (Google, Bing) crawl pages by requesting the URL and reading the HTML response. If your page is blank HTML waiting for JavaScript to run, crawlers may index nothing.
For public-facing pages that you want to appear in search results, use SSG or SSR. For private, authenticated pages (dashboards, account settings), SEO doesn't matter - but performance still does. SSR gives a faster first paint than CSR even for authenticated pages. CSR is a common choice there because the data is user-specific and can't be cached, but SSR is still an option if first-load performance matters.
Set page titles and descriptions using the metadata export:
// app/products/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'All Products',
description: 'Browse our full product catalogue',
}
export default async function ProductsPage() { ... }
For dynamic pages, export generateMetadata instead:
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await fetch(`https://api.example.com/products/${id}`).then(r => r.json())
return {
title: product.name,
description: product.description,
}
}
<aside> ⌨️
Hands on: Add a metadata export to your home page (src/app/page.tsx) with a custom title and description. Open the page in the browser and inspect the <head> - you should see <title> and <meta name="description"> tags injected by Next.js. Then add a generateMetadata function to a dynamic route (e.g. /posts/[id]) that sets the title to the fetched post's title.
</aside>
next/imageThe native <img> tag loads images at their original size. next/image automatically:
import Image from 'next/image'
export function ProductCard({ product }) {
return (
<div>
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
/>
<h2>{product.name}</h2>
</div>
)
}
For images that fill their container (responsive layouts), use fill:
<div style={{ position: 'relative', height: '300px' }}>
<Image
src={product.imageUrl}
alt={product.name}
fill
style={{ objectFit: 'cover' }}
/>
</div>
<aside> ⚠️
External images need to be allowlisted in next.config.ts. Add the hostname to images.remotePatterns:
</aside>
// next.config.ts
const config = {
images: {
remotePatterns: [
{ hostname: 'images.unsplash.com' },
{ hostname: 'cdn.example.com' },
],
},
}
Always provide width and height for next/image - this lets the browser reserve space before the image loads, preventing Cumulative Layout Shift (CLS), which affects both UX and SEO scores.
<aside> ⌨️
</aside>
next/fontnext/font automatically self-hosts Google Fonts - the font files are downloaded at build time and served from your own domain. This eliminates a network request to Google's servers on every page load, improving performance and privacy.
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // show system font until Inter loads
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}
For local fonts:
import localFont from 'next/font/local'
const myFont = localFont({ src: '../public/fonts/MyFont.woff2' })
next/font also handles font-display: swap and subset loading automatically - no <link> tags in your HTML or manual font file downloads needed.
<aside> ⌨️
</aside>