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
| Problem | React alone | Next.js |
|---|---|---|
| Routing | Requires react-router | Built-in file system routing |
| SEO | CSR limits crawler support | SSR/SSG delivers full HTML |
| Image optimization | Custom implementation | Built-in <Image> component |
| API server | Separate Node server needed | Built-in Route Handlers |
| Code splitting | Manual configuration | Automatic code splitting |
| Font optimization | Custom implementation | Built-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
| Aspect | Pages Router | App Router |
|---|---|---|
| Default component | Client Component | Server Component |
| Data fetching | getServerSideProps, getStaticProps | async/await directly in components |
| Layouts | _app.js, _document.js | layout.js (nestable) |
| Loading UI | Custom implementation | Automatic via loading.js |
| Error UI | _error.js | Automatic via error.js |
| Streaming | Not supported | Supported via <Suspense> |
| Caching | Simple | Fine-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
| Filename | Role |
|---|---|
page.js | UI for the route (makes the route publicly accessible) |
layout.js | Shared layout (preserved across navigations without re-render) |
loading.js | Loading UI (automatically wraps with Suspense) |
error.js | Error UI (automatically wraps with Error Boundary) |
not-found.js | 404 UI |
route.js | API Route Handler |
template.js | Layout that creates a new instance on each navigation |
default.js | Fallback 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:
paramsandsearchParamsare now Promises. You must unwrap them withawait.
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
useStateoruseEffect - 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
childrenprop 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>
);
}