React is a UI library. It handles rendering. That’s it. The moment you need routing, server-side rendering, API endpoints, image optimisation, or SEO — you’re on your own.
Next.js handles all of that. It’s a React framework — React with opinions, structure, and production-grade features built in. You write React components. Next.js decides how, when, and where they render.
Used in production by Vercel, TikTok, Twitch, Hulu, and most serious React applications.
What Next.js Adds Over React
React alone Next.js adds
────────────────────────── ──────────────────────────────────────
UI rendering File-based routing
Manual router setup Server-side rendering (SSR)
No built-in data fetching Static generation (SSG)
No API layer Incremental static regeneration (ISR)
Manual image handling API routes
DIY SEO setup Built-in image optimisation
Metadata / SEO management
Middleware
Streaming and Suspense
App Router vs Pages Router
Next.js has two routing systems. You’ll encounter both.
Pages Router — the original. Files in pages/ become routes. getServerSideProps, getStaticProps for data fetching. Still widely used in existing codebases.
App Router — introduced in Next.js 13, stable from 13.4. Files live in app/. Built around React Server Components. This is the current standard and what new projects use.
This article focuses on the App Router. Where the Pages Router differs meaningfully, it’ll be called out.
File-Based Routing
Forget react-router. In Next.js, your folder structure is your routes.
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/anything
└── dashboard/
├── layout.tsx → shared layout for /dashboard/*
├── page.tsx → /dashboard
└── settings/
└── page.tsx → /dashboard/settings
Create a folder, add a page.tsx inside it — that’s a route. No configuration, no <Route> components, no path strings to maintain.
The App Directory
Each route folder can have special files. Each file has a specific job:
app/
└── dashboard/
├── page.tsx ← the UI for this route (required to make it a route)
├── layout.tsx ← wraps page.tsx and all nested routes
├── loading.tsx ← shown while page.tsx is loading (Suspense boundary)
├── error.tsx ← shown when page.tsx throws an error (Error boundary)
├── not-found.tsx ← shown when notFound() is called
└── route.ts ← API endpoint for this path (no page.tsx if using this)
These are reserved filenames — Next.js treats them specially. Your own components use any other name.
Server Components vs Client Components
This is the most important concept in the App Router. Get this wrong and nothing else makes sense.
By default, every component in the app/ directory is a Server Component.
Server Components render on the server. The HTML is generated server-side and sent to the browser. The browser receives ready-to-display HTML — no JavaScript needed to render the UI.
Server Component Client Component
────────────────────────────────── ────────────────────────────────────
Renders on the server Renders in the browser
No `useState`, no `useEffect` Can use all React hooks
No browser APIs (window, document) Can access browser APIs
Can `await` directly (async fn) Cannot be async
Can access backend directly Cannot access backend directly
Zero JS sent to browser Ships JavaScript to the browser
Default in app/ directory Requires 'use client' at the top
When to use which:
Server Component Client Component
────────────────────── ────────────────────────────────
Fetch data Interactivity (onClick, onChange)
Read from a database useState, useEffect, useRef
Access environment vars Browser APIs (localStorage, window)
Heavy dependencies Real-time updates
Static or SEO content Form inputs, animations
To make a Client Component, add 'use client' at the top of the file:
'use client';
import { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
};
Without 'use client', using useState throws an error — Server Components can’t have state.
Composing them together:
// app/dashboard/page.tsx — Server Component (no directive needed)
import UserGreeting from './UserGreeting'; // Client Component
const DashboardPage = async () => {
const data = await fetch('https://api.example.com/stats').then(r => r.json());
return (
<div>
<h1>Stats: {data.total}</h1>
<UserGreeting /> {/* Client Component nested inside Server Component — fine */}
</div>
);
};
Server Components can render Client Components. Client Components cannot render Server Components (they’d lose the server-only guarantee). The boundary only flows one direction.
Pass server data to client components via props:
// Server Component fetches, Client Component displays interactively
const Page = async () => {
const user = await getUser(); // runs on server
return <UserCard user={user} />; // UserCard is a Client Component
};
Layouts
A layout.tsx file wraps every page inside its folder and all nested folders. It persists across navigations — it doesn’t re-render when you navigate between child routes.
// app/layout.tsx — root layout, wraps every page in the app
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Navbar />
{children}
<Footer />
</body>
</html>
);
}
The root layout (app/layout.tsx) is required. It must include <html> and <body> tags.
Nested layouts:
// app/dashboard/layout.tsx — wraps all /dashboard/* routes
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
);
}
/dashboard/settings renders:
RootLayout
└── DashboardLayout
└── SettingsPage
Layouts nest. Each level adds its own shell around the content inside it.
Dynamic Routes
For routes where part of the URL is variable — a blog post slug, a user ID, a product name.
Single dynamic segment — [slug]:
app/blog/[slug]/page.tsx → /blog/hello-world, /blog/react-intro
// app/blog/[slug]/page.tsx
type Props = { params: { slug: string } };
const BlogPost = ({ params }: Props) => {
return <h1>Post: {params.slug}</h1>;
};
Catch-all segments — [...slug]:
app/docs/[...slug]/page.tsx → /docs/a, /docs/a/b, /docs/a/b/c
// params.slug = ['a'], ['a', 'b'], ['a', 'b', 'c']
Optional catch-all — [[...slug]]:
app/docs/[[...slug]]/page.tsx → /docs AND /docs/a/b/c
Generating static pages for dynamic routes:
// Tell Next.js which slugs to pre-render at build time
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(post => ({ slug: post.slug }));
}
Route Groups
Organise routes without affecting the URL. Wrap a folder name in parentheses.
app/
├── (marketing)/
│ ├── about/page.tsx → /about
│ └── blog/page.tsx → /blog
└── (dashboard)/
├── layout.tsx ← only applies to dashboard routes
└── settings/page.tsx → /settings
(marketing) and (dashboard) are invisible in the URL. Use them to apply different layouts to different sections of the site, or just to keep related routes together.
Loading and Error States
loading.tsx
Place a loading.tsx file in any route folder and Next.js wraps the page.tsx in a <Suspense> boundary automatically. While the page is fetching, loading.tsx renders.
// app/dashboard/loading.tsx
const Loading = () => <div className="spinner">Loading...</div>;
export default Loading;
No manual <Suspense> needed — Next.js wires it up.
error.tsx
Catches errors thrown anywhere inside the route (including nested layouts and components). Must be a Client Component — it uses React error boundary internals.
'use client';
type Props = { error: Error; reset: () => void };
const ErrorPage = ({ error, reset }: Props) => {
return (
<div>
<h2>Something went wrong.</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
};
export default ErrorPage;
reset re-renders the route segment. Useful for transient errors.
not-found.tsx
Renders when notFound() is called inside a Server Component:
import { notFound } from 'next/navigation';
const BlogPost = async ({ params }: Props) => {
const post = await getPost(params.slug);
if (!post) notFound(); // renders not-found.tsx
return <article>{post.content}</article>;
};
Rendering Strategies
This is where Next.js earns its keep. Four ways to render a page — each a trade-off between freshness, performance, and server load.
Strategy When rendered Data freshness Good for
────────── ───────────────────── ──────────────── ─────────────────────────
SSG Build time Stale Marketing, docs, blogs
ISR Build time + revalidate Semi-fresh Product pages, news
SSR Every request Always fresh User dashboards, carts
CSR In the browser Always fresh Highly interactive UIs
SSG — Static Site Generation
Rendered once at build time. The fastest possible response — the HTML is pre-built and served from a CDN.
// Fetch with no special option — defaults to 'force-cache' (SSG)
const Page = async () => {
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
}).then(r => r.json());
return <PostList posts={data} />;
};
ISR — Incremental Static Regeneration
Static at first, but revalidates in the background after a time window. The best of SSG performance with semi-fresh data.
const Page = async () => {
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }, // regenerate at most every 60 seconds
}).then(r => r.json());
return <PostList posts={data} />;
};
On-demand revalidation — trigger a rebuild from an API route:
import { revalidatePath, revalidateTag } from 'next/cache';
revalidatePath('/blog'); // revalidate a specific path
revalidateTag('posts'); // revalidate all fetches tagged 'posts'
SSR — Server-Side Rendering
Rendered fresh on every request. Always up-to-date, but slower than static — the server must render before responding.
const Page = async () => {
const data = await fetch('https://api.example.com/cart', {
cache: 'no-store', // never cache — always fetch fresh
}).then(r => r.json());
return <CartView items={data} />;
};
Also triggered automatically when you read request-time data (cookies(), headers(), searchParams).
CSR — Client-Side Rendering
The traditional React way. Fetch data in the browser after the component mounts. Useful for highly interactive, user-specific data that doesn’t need SEO.
'use client';
import { useState, useEffect } from 'react';
const LiveFeed = () => {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/feed').then(r => r.json()).then(setData);
}, []);
if (!data) return <p>Loading...</p>;
return <FeedList items={data} />;
};
Data Fetching in Server Components
Server Components can be async — you await data directly inside the component. No useEffect, no loading state wiring, no useState.
// app/users/page.tsx
const UsersPage = async () => {
const users = await fetch('https://api.example.com/users').then(r => r.json());
return (
<ul>
{users.map((user: User) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
Parallel data fetching — don’t await sequentially if you don’t have to:
const Page = async () => {
// Sequential — slow (waits for each before starting the next)
const user = await getUser();
const posts = await getPosts();
// Parallel — fast (both start simultaneously)
const [user, posts] = await Promise.all([getUser(), getPosts()]);
return <Dashboard user={user} posts={posts} />;
};
Fetch deduplication — Next.js automatically deduplicates identical fetch calls within a render. Call fetch('/api/user') in five different Server Components in the same request — it runs once.
API Routes
Create backend endpoints without a separate server. In the App Router, a route.ts file handles HTTP methods.
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const users = await db.users.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.users.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
Named exports match HTTP methods: GET, POST, PUT, PATCH, DELETE.
Dynamic API routes:
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.users.findById(params.id);
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json(user);
}
Navigation
<Link> — client-side navigation without a full page reload. Prefetches the linked page in the background.
import Link from 'next/link';
const Nav = () => (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href={`/blog/${post.slug}`}>Read Post</Link>
</nav>
);
useRouter — programmatic navigation. Client Component only.
'use client';
import { useRouter } from 'next/navigation';
const LoginForm = () => {
const router = useRouter();
const handleLogin = async () => {
await login();
router.push('/dashboard'); // navigate
router.replace('/dashboard'); // navigate without adding to history
router.back(); // go back
router.refresh(); // re-fetch server data for current route
};
};
redirect() — server-side redirect. Use in Server Components, Server Actions, and API routes.
import { redirect } from 'next/navigation';
const Page = async () => {
const session = await getSession();
if (!session) redirect('/login'); // throws internally, stops execution
return <Dashboard />;
};
usePathname and useSearchParams — read the current URL. Client Components only.
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
const BreadCrumb = () => {
const pathname = usePathname(); // '/dashboard/settings'
const searchParams = useSearchParams();
const query = searchParams.get('q'); // ?q=hello → 'hello'
};
Middleware
Runs before a request is completed — before the page renders, before the API route responds. Lives at the root in middleware.ts.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next(); // continue to the route
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};
Common middleware use cases:
- Auth checks — redirect unauthenticated users
- Geolocation-based routing
- A/B testing
- Request logging
- Adding response headers
matcher limits which routes the middleware runs on. Without it, every request goes through it.
Image Optimisation
The <Image> component from next/image replaces <img>. It automatically:
- Resizes images for the device
- Serves modern formats (WebP, AVIF)
- Lazy loads by default
- Prevents layout shift (requires width/height or
fill)
import Image from 'next/image';
// Local image — TypeScript imports give you width/height automatically
import avatar from './avatar.png';
const Profile = () => (
<Image
src={avatar}
alt="User avatar"
width={80}
height={80}
/>
);
// Remote image — must add domain to next.config.js
const Card = () => (
<Image
src="https://example.com/photo.jpg"
alt="Photo"
width={400}
height={300}
priority // load eagerly (above the fold images)
/>
);
// Fill parent container
const Banner = () => (
<div style={{ position: 'relative', height: '400px' }}>
<Image
src="/hero.jpg"
alt="Hero"
fill
style={{ objectFit: 'cover' }}
/>
</div>
);
Remote images need whitelisting in next.config.js:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'example.com' },
],
},
};
Metadata and SEO
Set page titles, descriptions, Open Graph tags without touching HTML manually.
Static metadata:
// app/about/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn more about our company.',
openGraph: {
title: 'About Us',
description: 'Learn more about our company.',
images: [{ url: '/og-about.png' }],
},
};
const AboutPage = () => <main>...</main>;
Dynamic metadata — when the title depends on fetched data:
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
Root metadata template — set a base title that all pages build on:
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | My Site', // %s is replaced by each page's title
default: 'My Site',
},
};
// A page with title: 'About' renders: "About | My Site"
Environment Variables
# .env.local
DATABASE_URL=postgres://localhost/mydb
NEXT_PUBLIC_API_URL=https://api.example.com
- Variables without
NEXT_PUBLIC_— server-side only. Safe for secrets, API keys, database URLs. - Variables prefixed with
NEXT_PUBLIC_— bundled into the client. Visible in the browser. Never put secrets here.
// Server Component or API route — access both
process.env.DATABASE_URL // ✓
process.env.NEXT_PUBLIC_API_URL // ✓
// Client Component — only NEXT_PUBLIC_ is available
process.env.DATABASE_URL // undefined (intentionally stripped)
process.env.NEXT_PUBLIC_API_URL // ✓
Server Actions
Introduced in Next.js 13.4. Functions that run on the server, called directly from Client Components. No API route needed for form submissions and mutations.
// app/actions.ts
'use server'; // marks this module as server-only
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
await db.users.create({ data: { name } });
}
// app/new-user/page.tsx — Server Component
import { createUser } from '../actions';
const NewUserPage = () => (
<form action={createUser}> {/* Next.js wires up the server call */}
<input name="name" type="text" />
<button type="submit">Create</button>
</form>
);
From a Client Component using useTransition:
'use client';
import { useTransition } from 'react';
import { createUser } from '../actions';
const NewUserForm = () => {
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
startTransition(() => createUser(formData));
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<button disabled={isPending}>{isPending ? 'Saving...' : 'Save'}</button>
</form>
);
};
Streaming with Suspense
Instead of waiting for the entire page to render before sending anything, stream parts of the page as they’re ready.
import { Suspense } from 'react';
const Page = () => (
<div>
<Header /> {/* renders immediately */}
<Suspense fallback={<Skeleton />}>
<SlowDataComponent /> {/* streams in when ready */}
</Suspense>
<Suspense fallback={<Spinner />}>
<AnotherSlowComponent /> {/* streams in independently */}
</Suspense>
</div>
);
The browser receives the shell HTML immediately and fills in the slow parts as the server finishes them. Users see content faster even if some of it takes time.
Interview Essentials
Server vs Client — the decision tree
Does the component need onClick, onChange, or any event handler? → Client
Does it use useState, useEffect, useRef, or any other hook? → Client
Does it access browser APIs (window, localStorage)? → Client
Does it need to fetch data or read from a database? → Server
Is it static content with no interactivity? → Server
Does it need to run before the page reaches the user? → Server
Push 'use client' as far down the component tree as possible. A page can be mostly Server Components with a small interactive island as a Client Component.
How caching works in the App Router
Next.js has four caching layers that work together:
Layer Stores Duration
───────────────────── ──────────────────────────── ─────────────────
Request memoisation fetch() results per request Single request
Data cache fetch() results Until revalidated
Full route cache Rendered HTML + RSC payload Until revalidated
Router cache RSC payload on client Session / short TTL
fetch() calls are cached by default. cache: 'no-store' opts out. next: { revalidate: N } sets a TTL. This is what makes Next.js fast — most responses never hit your server or database.
Pages Router data fetching (you’ll see this in older codebases)
// SSG — runs at build time
export async function getStaticProps() {
const data = await getData();
return { props: { data } };
}
// SSR — runs on every request
export async function getServerSideProps(context) {
const data = await getData();
return { props: { data } };
}
// Dynamic SSG — which paths to pre-render
export async function getStaticPaths() {
return { paths: [{ params: { slug: 'hello' } }], fallback: false };
}
In the App Router, none of these exist. They’re replaced by async Server Components with fetch() options.
useRouter from next/router vs next/navigation
next/router → Pages Router only
next/navigation → App Router only
Using next/router in an App Router project breaks. This trips people up when reading older tutorials.
Why <Link> over <a>
<a> causes a full page reload — the browser fetches everything from scratch. <Link> does client-side navigation — only the changed parts of the page update, the rest stays. It also prefetches linked pages in the viewport in the background, so navigation feels instant.
What is ISR and why does it matter
Static pages are fast but stale. SSR pages are fresh but slow. ISR gives you both: the page is pre-rendered (fast), but Next.js regenerates it in the background after the revalidation window expires. Users always get a fast response. Data is never older than revalidate seconds.
Middleware runs on the Edge
Middleware doesn’t run in Node.js — it runs on the Edge Runtime (V8 isolates). This means no Node.js APIs (fs, path, crypto). It’s designed to be fast and globally distributed, not feature-rich.
Server Actions replace API routes for mutations
The old pattern: Client Component → fetch('/api/route') → API route → database. The new pattern: Client Component → Server Action → database. No HTTP request, no route file, same security model. Use API routes when you need a public-facing HTTP endpoint (webhooks, mobile clients, third-party integrations). Use Server Actions for everything internal.
The Mental Model
Next.js is a server that speaks React. When a request comes in, it decides: has this page been pre-built? Serve it instantly. Is it dynamic? Render it now on the server. Is part of it interactive? Hydrate just that part in the browser.
The App Router’s big idea: components are server-first. Rendering on the server is the default. You opt into the browser — not the other way around. This flips the React mental model, but it means less JavaScript shipped, faster pages, and direct database access without an API layer in between.
Understand the server/client boundary. Everything else — routing, caching, streaming — is built on top of that one idea.