Next.js 12 Data Fetching, SSG, SSR, and ISR Explained
TL;DR —
getStaticProps(SSG) for content known at build time. Plus ISR withrevalidatefor content that changes occasionally.getServerSideProps(SSR) for per-request data, auth-gated pages, or anything that can’t be cached. Client-side fetching (SWR/React Query) for in-app interactions. Pick the slowest strategy that meets your freshness needs.
After routing, the next Next.js concept is data fetching. There are three server-side strategies (SSG, SSR, ISR) plus client-side fetching for in-app data. Picking the right one per page is the difference between a fast site and a slow one.
This post walks through each strategy with the cases where each one wins, and the gotchas that catch teams new to Next.js.
getStaticProps (SSG) — build-time data
getStaticProps runs at build time. The page renders to HTML once and serves as static files from then on. Fastest possible page; oldest possible data.
// pages/blog/[slug].tsx
import type { GetStaticProps, GetStaticPaths } from 'next';
interface Post {
slug: string;
title: string;
body: string;
}
interface Props { post: Post }
export default function BlogPost({ post }: Props) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.body }} />
</article>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await loadAllPosts();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const post = await loadPost(params!.slug as string);
if (!post) return { notFound: true };
return { props: { post } };
};
Two functions for dynamic SSG routes:
getStaticPaths— which paths exist? Build time enumeration.getStaticProps— for each path, what data? Build time fetch.
fallback: 'blocking' means new paths not in getStaticPaths get server-rendered on first request, then cached. Other options:
false— only paths ingetStaticPathsexist; everything else 404strue— render placeholder, fetch in background (rare; weird UX)
For most cases, 'blocking' is right.
Use SSG for: marketing pages, blog posts, documentation, product catalogs (with thousands of paths). Anything where “data hasn’t changed in 5 minutes” is acceptable.
getStaticProps + revalidate (ISR) — eventually-fresh static
Add revalidate to getStaticProps’s return:
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const post = await loadPost(params!.slug as string);
return {
props: { post },
revalidate: 60, // re-generate at most every 60 seconds
};
};
Incremental Static Regeneration. Page is static until revalidate seconds pass; next request triggers a background regeneration; subsequent requests get the new version.
The flow per page:
- User requests
/blog/foo. Cached static HTML served instantly. - If > 60 seconds since last regeneration, Next.js triggers a regen in the background. Current request still gets old HTML.
- Regen completes, cache updates.
- Next request gets fresh HTML.
So users see stale-while-revalidating data. Fast pages, eventually-consistent.
Use ISR for: product pages with prices that change daily, blog post lists, anything where “5-minute lag is fine but 5-hour lag isn’t.”
For on-demand revalidation (immediately invalidate a specific page on a webhook), Next.js 12.1 added res.revalidate(path) in API routes:
// pages/api/revalidate.ts
export default async function handler(req, res) {
if (req.headers['x-secret'] !== process.env.REVALIDATE_SECRET) {
return res.status(401).end();
}
await res.revalidate('/blog/' + req.query.slug);
res.json({ revalidated: true });
}
Webhook from your CMS calls this, page updates immediately.
getServerSideProps (SSR) — per-request data
Runs on every request. Page is rendered fresh each time.
import type { GetServerSideProps } from 'next';
export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
const session = await getSession(ctx.req);
if (!session) {
return { redirect: { destination: '/login', permanent: false } };
}
const user = await loadUser(session.userId);
return { props: { user } };
};
export default function Dashboard({ user }) {
return <h1>Welcome {user.name}</h1>;
}
Use SSR for:
- Pages that depend on the current user (auth-gated, personalized)
- Pages whose data changes per-request (live prices, dashboards)
- Pages that need request context (headers, cookies, IP)
Cost: every request hits your server. No CDN caching of the HTML. Latency = your server’s roundtrip.
Client-side fetching — for in-app interactions
For data that loads after the page loads (e.g., a list that paginates, a chart that updates), don’t use SSR/SSG — use client-side fetching:
import useSWR from 'swr';
function OrderList() {
const { data, error, isLoading } = useSWR('/api/orders', fetcher);
if (isLoading) return <Skeleton />;
if (error) return <Error />;
return <ul>{data.orders.map(o => <li key={o.id}>{o.id}</li>)}</ul>;
}
SWR or React Query handle caching, revalidation, and loading states. Pair with Suspense if you want declarative loading.
Decision matrix
The cheat sheet I use:
| Question | Strategy |
|---|---|
| Same content for all users, changes rarely (docs, marketing) | SSG (getStaticProps) |
| Same for all users, changes occasionally (blog, products) | SSG + ISR |
| Different per user, can’t cache (dashboard, profile) | SSR (getServerSideProps) |
| Loaded after initial page render (in-app interactions) | Client (SWR/React Query) |
| Mix: shell SSG/SSR, body dynamic | SSG + client fetching |
Default to SSG. Promote to ISR if updates need to land within minutes. Promote to SSR only when per-request data is genuinely required.
A real-world layout
For our blog (the one you’re reading):
| Page | Strategy |
|---|---|
/ (homepage) |
SSG |
/blog/ (post list) |
SSG + ISR (revalidate 300) |
/blog/[slug] (post) |
SSG + ISR (revalidate 600) |
/tracks/[track]/ |
SSG + ISR |
/contact/ |
SSG |
No SSR anywhere. The blog has no per-user content. Every page is statically built, with ISR for the post list (so new posts appear within 5 minutes without rebuild).
For our internal admin dashboard, by contrast:
| Page | Strategy |
|---|---|
/login/ |
SSG |
/dashboard/ |
SSR (per-user) |
/orders/[id]/ |
SSR (per-user, real-time data) |
/orders/ (list with filters) |
SSR shell + client-side pagination |
Different shape of app, different strategy mix.
getServerSideProps gotchas
A few specifics worth knowing:
Don’t fetch from your own API. If page X needs data from /api/data, call your data layer directly inside getServerSideProps, not via fetch to your own API. Avoids a round trip.
Cache headers don’t apply to the HTML. SSR pages are dynamic; CDNs won’t cache them. If your data is mostly-static, use ISR instead.
ctx.req and ctx.res are Node objects. You can read cookies, set response headers, etc. Useful for auth — but easy to leak server-only logic into client bundles.
Errors throw to a 500 page. Wrap in try/catch and use { notFound: true } or { redirect: ... } for non-500 outcomes.
Common Pitfalls
Using SSR by default. It’s the slowest. Default to SSG; promote only when needed.
SSR with no auth check. “Page renders on the server” doesn’t mean “page is secret.” SSR pages are returned to anyone who requests them. Auth-check inside getServerSideProps.
Client-side fetching for above-the-fold data. Causes layout shift and slow LCP. Use SSG/SSR for what’s in the first viewport; client for what’s below.
ISR revalidate: 1. Effectively SSR with extra steps and worse latency. If you need per-request freshness, just use SSR.
Forgetting getStaticPaths.fallback. Without it, dynamic SSG routes 404 for paths not pre-generated. 'blocking' is the safe default.
Defining a page as async function. Pages must be regular React components. Async happens in getStaticProps / getServerSideProps.
Importing server-only code into pages. A page file is bundled for both server and client (the component part). Anything you import is included in both. Server-only deps (database client, secrets) should only be imported inside getStaticProps / getServerSideProps, which are tree-shaken from the client bundle.
Wrapping Up
Pick the slowest data-fetching strategy that meets your freshness requirements. Default to SSG, add ISR when needed, reach for SSR only for genuinely per-request data, use client-side fetching for everything that loads after the shell. Friday: Next.js middleware for auth and redirects — running code before your pages render.