Skip to main content

Introduction to Next.js — Pages Router vs App Router, File-based Routing, SSR/SSG/ISR

What is Next.js?​

Next.js is a full-stack web framework built on top of React. Developed and maintained by Vercel, it provides built-in solutions for common web development challenges such as server-side rendering (SSR), static site generation (SSG), and file-based routing.

Because React itself is a UI library, you have to manually configure routing, data fetching, build optimization, and more. Next.js is an opinionated framework that solves these common problems out of the box.

Problems Next.js Solves​

ProblemReact aloneNext.js
RoutingRequires react-routerBuilt-in file system routing
SEOCSR limits crawler supportSSR/SSG delivers full HTML
Image optimizationCustom implementationBuilt-in <Image> component
API serverSeparate Node server neededBuilt-in Route Handlers
Code splittingManual configurationAutomatic code splitting
Font optimizationCustom implementationBuilt-in next/font

Pages Router vs App Router​

Two routing systems coexist in Next.js.

Pages Router (legacy, primary in Next.js 12 and below)​

Traditional routing based on the pages/ directory.

pages/
index.js → /
about.js → /about
blog/
[slug].js → /blog/:slug
api/
users.js → /api/users (API Route)

Data fetching in Pages Router:

// pages/posts/[id].js — Pages Router style
export async function getServerSideProps({ params }) {
// Runs on the server on every request (SSR)
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();

return { props: { post } };
}

export async function getStaticProps({ params }) {
// Runs at build time (SSG)
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();

return {
props: { post },
revalidate: 60, // ISR: regenerate every 60 seconds
};
}

export async function getStaticPaths() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();

return {
paths: posts.map((p) => ({ params: { id: String(p.id) } })),
fallback: 'blocking',
};
}

export default function PostPage({ post }) {
return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}

App Router (Next.js 13+, current standard)​

New routing based on the app/ directory. It uses React Server Components (RSC) at its core and was stabilized in Next.js 15.

app/
layout.js → Root layout (shared across all pages)
page.js → / route
about/
page.js → /about
blog/
layout.js → Shared layout for /blog and its children
page.js → /blog
[slug]/
page.js → /blog/:slug
api/
users/
route.js → /api/users (Route Handler)

Key Differences: Pages Router vs App Router​

AspectPages RouterApp Router
Default componentClient ComponentServer Component
Data fetchinggetServerSideProps, getStaticPropsasync/await directly in components
Layouts_app.js, _document.jslayout.js (nestable)
Loading UICustom implementationAutomatic via loading.js
Error UI_error.jsAutomatic via error.js
StreamingNot supportedSupported via <Suspense>
CachingSimpleFine-grained per-request caching

From Next.js 15, App Router is the standard. Use App Router for all new projects.


File-based Routing (App Router)​

In App Router, routing is determined by the file system structure.

Special File Conventions​

FilenameRole
page.jsUI for the route (makes the route publicly accessible)
layout.jsShared layout (preserved across navigations without re-render)
loading.jsLoading UI (automatically wraps with Suspense)
error.jsError UI (automatically wraps with Error Boundary)
not-found.js404 UI
route.jsAPI Route Handler
template.jsLayout that creates a new instance on each navigation
default.jsFallback for Parallel Routes

Dynamic Routes​

app/
blog/
[slug]/ → /blog/:slug (single dynamic segment)
page.js
shop/
[...categories]/ → /shop/a/b/c (catch-all)
page.js
docs/
[[...slug]]/ → /docs or /docs/a/b (optional catch-all)
page.js

Accessing dynamic parameters:

// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function BlogPost({ params, searchParams }: Props) {
const { slug } = await params;
const { page = '1' } = await searchParams;

return (
<article>
<h1>Post: {slug}</h1>
<p>Page: {page}</p>
</article>
);
}

Next.js 15 change: params and searchParams are now Promises. You must unwrap them with await.

Route Groups​

You can logically group files without affecting the URL path.

app/
(marketing)/ → Not included in URL
page.js → /
about/page.js → /about
(shop)/ → Not included in URL
products/page.js → /products
cart/page.js → /cart
(auth)/
layout.js → Auth-specific layout
login/page.js → /login
register/page.js → /register

Parallel Routes & Intercepting Routes​

app/
@modal/ → Parallel route (slot)
(.)photo/[id]/ → Intercepting: opens as modal in current layout
page.js
photo/
[id]/
page.js → Full page when accessed directly
layout.js → Receives { children, modal } props

SSR / SSG / ISR Rendering Strategies​

1. SSR — Server-Side Rendering​

HTML is generated on the server on every request.

// app/dashboard/page.tsx
// Without caching, behaves as SSR by default
export default async function Dashboard() {
// Runs on every request
const data = await fetch('https://api.example.com/stats', {
cache: 'no-store', // SSR: disable caching
}).then((r) => r.json());

return (
<main>
<h1>Dashboard</h1>
<p>Active users: {data.activeUsers}</p>
</main>
);
}

When to use: Real-time data, personalized pages per user, pages requiring authentication

2. SSG — Static Site Generation​

HTML is pre-generated at build time.

// app/blog/[slug]/page.tsx
// generateStaticParams generates the path list at build time
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 BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;

// fetch is cached by default (SSG)
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) =>
r.json()
);

return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}

When to use: Blogs, documentation, marketing pages, and other static content

3. ISR — Incremental Static Regeneration​

Combines the benefits of SSG (fast responses) and SSR (up-to-date data).

// app/products/page.tsx
export const revalidate = 3600; // Revalidate every 1 hour

export default async function Products() {
const products = await fetch('https://api.example.com/products').then((r) =>
r.json()
);

return (
<ul>
{products.map((p: { id: number; name: string; price: number }) => (
<li key={p.id}>
{p.name} — ₩{p.price.toLocaleString()}
</li>
))}
</ul>
);
}

On-Demand ISR: Immediately revalidate when a specific event occurs

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');

if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}

const tag = searchParams.get('tag');
if (tag) {
revalidateTag(tag); // Invalidate cache for a specific tag
}

const path = searchParams.get('path');
if (path) {
revalidatePath(path); // Invalidate cache for a specific path
}

return NextResponse.json({ revalidated: true, now: Date.now() });
}

Rendering Strategy Decision Guide​

Does the data change frequently?
├── Yes → Is it different per user?
│ ├── Yes → SSR (cache: 'no-store')
│ └── No → ISR (set revalidate)
└── No → SSG (generateStaticParams)

Practical Example: Blog Site Structure​

my-blog/
app/
layout.tsx # Root layout
page.tsx # Home (SSG)
blog/
page.tsx # Blog list (ISR, 1 hour)
[slug]/
page.tsx # Blog detail (SSG + generateStaticParams)
about/
page.tsx # About (SSG)
api/
revalidate/
route.ts # ISR webhook endpoint
components/
Header.tsx
Footer.tsx
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Header from '@/components/Header';
import Footer from '@/components/Footer';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
title: {
template: '%s | My Blog',
default: 'My Blog',
},
description: 'A blog built with Next.js App Router',
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
// app/blog/page.tsx — ISR
export const revalidate = 3600;

interface Post {
slug: string;
title: string;
date: string;
excerpt: string;
}

export default async function BlogList() {
const posts: Post[] = await fetch('https://api.example.com/posts').then(
(r) => r.json()
);

return (
<section>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<time>{post.date}</time>
<p>{post.excerpt}</p>
</a>
</li>
))}
</ul>
</section>
);
}
// app/blog/[slug]/page.tsx — SSG
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface Post {
slug: string;
title: string;
date: string;
content: string;
}

export async function generateStaticParams() {
const posts: Post[] = await fetch('https://api.example.com/posts').then(
(r) => r.json()
);
return posts.map((p) => ({ slug: p.slug }));
}

export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post: Post = await fetch(
`https://api.example.com/posts/${slug}`
).then((r) => r.json());

return {
title: post.title,
description: post.content.slice(0, 160),
};
}

export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`);

if (!res.ok) notFound();

const post: Post = await res.json();

return (
<article>
<h1>{post.title}</h1>
<time>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// app/blog/[slug]/loading.tsx — Automatic Suspense
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/4 mb-8" />
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded" />
))}
</div>
</div>
);
}
// app/blog/[slug]/error.tsx — Automatic Error Boundary
'use client'; // Error components must be Client Components

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}

Server Components vs Client Components​

This is a core concept of App Router.

Server Component (default)​

// Server Component when 'use client' is absent
// app/components/ProductList.tsx
async function ProductList() {
// Runs only on the server — can access API keys, DB, etc.
const products = await fetch('https://api.example.com/products', {
headers: { Authorization: `Bearer ${process.env.API_SECRET_KEY}` },
}).then((r) => r.json());

return (
<ul>
{products.map((p: { id: number; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
  • Zero bundle size (no JS sent to the client)
  • Cannot use useState or useEffect
  • Cannot use event handlers
  • Cannot use browser APIs

Client Component​

'use client'; // Must be at the very top of the file

import { useState } from 'react';

export default function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

Composition Pattern: Server → Client​

// app/page.tsx (Server Component)
import SearchBar from '@/components/SearchBar'; // Client Component
import ProductList from '@/components/ProductList'; // Server Component

export default async function Home() {
const featured = await fetch('https://api.example.com/featured').then((r) =>
r.json()
);

return (
<div>
{/* Pass server data to Client Component via props */}
<SearchBar />
<ProductList products={featured} />
</div>
);
}

Important: You cannot directly import a Server Component inside a Client Component. Use the children prop for composition instead.


Pro Tips​

1. Fine-grained Rendering Strategies​

You can apply different strategies to different components on the same page.

// app/dashboard/page.tsx
import { Suspense } from 'react';

// Static header (SSG)
function StaticHeader() {
return <h1>Dashboard</h1>;
}

// Dynamic stats (SSR)
async function LiveStats() {
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store',
}).then((r) => r.json());
return <div>Live: {stats.value}</div>;
}

// ISR chart (1-minute cache)
async function CachedChart() {
const data = await fetch('https://api.example.com/chart-data', {
next: { revalidate: 60 },
}).then((r) => r.json());
return <div>Chart entries: {data.length}</div>;
}

export default function Dashboard() {
return (
<div>
<StaticHeader />
<Suspense fallback={<div>Loading stats...</div>}>
<LiveStats />
</Suspense>
<Suspense fallback={<div>Loading chart...</div>}>
<CachedChart />
</Suspense>
</div>
);
}

2. Fetch Request Deduplication (Request Memoization)​

Fetch calls to the same URL are automatically deduplicated during a render pass.

// Even if two components fetch the same URL, only one actual network request is made
async function UserName() {
const user = await fetch('/api/user').then((r) => r.json()); // 1st call
return <span>{user.name}</span>;
}

async function UserAvatar() {
const user = await fetch('/api/user').then((r) => r.json()); // Deduplicated
return <img src={user.avatar} alt="avatar" />;
}

3. Automatic Metadata Generation​

// Generate dynamic OG tags with generateMetadata
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await fetch(`/api/posts/${slug}`).then((r) => r.json());

return {
title: post.title,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.ogImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
},
};
}

4. Authentication with Middleware​

// middleware.ts (at the root level)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;

if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}

return NextResponse.next();
}

export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};

5. Partial Prerendering (PPR) — Experimental Feature​

An experimental hybrid rendering feature in Next.js 15. It immediately serves a static shell while streaming in the dynamic parts.

// next.config.ts
const nextConfig = {
experimental: {
ppr: 'incremental', // or true (apply to entire app)
},
};

// app/product/[id]/page.tsx
export const experimental_ppr = true;

import { Suspense } from 'react';

export default function ProductPage() {
return (
<div>
{/* Static part: HTML served immediately */}
<StaticProductInfo />
{/* Dynamic part: streamed in later */}
<Suspense fallback={<div>Checking inventory...</div>}>
<DynamicInventory />
</Suspense>
</div>
);
}