All posts

Next.js — Introduction

App Router, Server and Client Components, rendering strategies, data fetching, middleware, and the mental model for building production Next.js apps — with interview coverage.


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

<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.

<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.