Skip to main content
Advertisement

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>
);
}
Advertisement