Next.js

Next.js is what you reach for when React alone isn’t enough. A plain React app sends an empty HTML shell and lets JavaScript do everything β€” which means slow first loads, poor SEO, and users staring at a blank page while the bundle downloads. Next.js runs React on the server first, sends real HTML immediately, then hands off to React client-side. The mental shift is small but the performance difference is significant.

It also handles routing, bundling, image optimization, fonts, and a growing chunk of what used to require hand-rolled infrastructure. Whether that feels like convenience or magic depends on how much you like understanding what’s happening underneath.


🟒 Junior

Creating a Project

npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm run dev   # development server at http://localhost:3000

Rendering Strategies

Strategy When HTML is generated Data freshness Use case
SSG (Static Site Generation) At build time Stale until rebuild Marketing pages, docs, blogs
ISR (Incremental Static Regeneration) Build + background revalidation Fresh within revalidate window Product pages, news
SSR (Server-Side Rendering) Every request Always fresh User-specific pages, real-time dashboards
CSR (Client-Side Rendering) In the browser On fetch Auth-gated dashboards, interactive tools

App Router File Structure (Next.js 13.4+ β€” Current Standard)

app/
  layout.tsx           β†’ root layout (persistent shell, wraps all pages)
  page.tsx             β†’ renders at /
  globals.css          β†’ global styles

  users/
    page.tsx           β†’ renders at /users
    [id]/
      page.tsx         β†’ renders at /users/123
      loading.tsx      β†’ shown while the segment loads (Suspense boundary)
      error.tsx        β†’ shown if the segment throws (Error boundary)
      not-found.tsx    β†’ rendered when notFound() is called

  (auth)/              β†’ route group β€” segments inside share layouts without adding to the URL
    login/page.tsx     β†’ renders at /login
    register/page.tsx  β†’ renders at /register

  api/
    users/
      route.ts         β†’ Route Handler at /api/users (GET, POST, etc.)

Pages: Basic Server Component

By default every file in app/ is a Server Component β€” it runs only on the server. No JavaScript is sent to the client for it.

// app/users/page.tsx
// This runs on the server β€” you can use async/await, access databases directly
export default async function UsersPage() {
  const users = await fetch('https://api.example.com/users').then(r => r.json());

  return (
    <main>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name} β€” {user.email}</li>
        ))}
      </ul>
    </main>
  );
}

Client Components β€” Adding Interactivity

When you need useState, useEffect, event handlers, or browser APIs, mark the component with 'use client'.

// components/SearchBox.tsx
'use client';

import { useState } from 'react';

export default function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      onKeyDown={e => e.key === 'Enter' && onSearch(query)}
      placeholder="Search..."
    />
  );
}

Layouts β€” Persistent UI Shell

Layouts wrap pages and persist across navigations β€” they don’t re-render when the child page changes.

// app/layout.tsx β€” root layout, wraps everything
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <a href="/">Home</a>
          <a href="/users">Users</a>
        </nav>
        <main>{children}</main>
        <footer>Β© 2026</footer>
      </body>
    </html>
  );
}

// app/dashboard/layout.tsx β€” nested layout for /dashboard/* routes
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <DashboardSidebar />
      <section>{children}</section>
    </div>
  );
}

Image and Font Optimization

import Image from 'next/image';
import { Nunito } from 'next/font/google';

// Font β€” loaded at build time, self-hosted, zero layout shift
const nunito = Nunito({ subsets: ['latin'], weight: ['400', '700'] });

// Image β€” automatically converts to WebP, lazy-loads, prevents layout shift
export default function HeroSection() {
  return (
    <div className={nunito.className}>
      <Image
        src="/hero.jpg"
        alt="Hero image"
        width={1200}
        height={600}
        priority    // preload β€” use for above-the-fold images only
      />
    </div>
  );
}

🟑 Medior

Server vs Client Components β€” The Mental Model

Server Component (default)         Client Component ('use client')
━━━━━━━━━━━━━━━━━━━━━━━━━━         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
βœ… Can be async                    ❌ Cannot be async
βœ… Direct DB access                ❌ No server-only code
βœ… Access file system              βœ… useState / useEffect / hooks
βœ… Zero JS bundle impact           βœ… onClick / onChange / events
βœ… Read server-side env vars       βœ… Browser APIs (window, document)
❌ No React hooks                  βœ… useRouter, useSearchParams

Composition pattern β€” Server wraps Client, passing data as props:

// app/users/page.tsx β€” Server Component fetches data
export default async function UsersPage() {
  const users = await db.getUsers();       // runs on server
  return <UserList initialUsers={users} />; // pass to Client Component
}

// components/UserList.tsx β€” Client Component handles interaction
'use client';
export default function UserList({ initialUsers }) {
  const [users, setUsers] = useState(initialUsers); // hydrated from server data
  const [search, setSearch] = useState('');

  const filtered = users.filter(u => u.name.includes(search));

  return (
    <>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      {filtered.map(u => <div key={u.id}>{u.name}</div>)}
    </>
  );
}

Data Fetching (App Router)

Next.js extends the fetch API with caching controls:

// SSG β€” cached indefinitely (until on-demand revalidation)
const data = await fetch('https://api.example.com/posts', {
  cache: 'force-cache',
});

// ISR β€” revalidate every 60 seconds in the background
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 60 },
});

// SSR β€” never cached, fresh every request (default in Next.js 15)
const data = await fetch('https://api.example.com/posts', {
  cache: 'no-store',
});

// Tag-based revalidation β€” invalidate all fetches with this tag
const data = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});

// Trigger on-demand from a Server Action or Route Handler
import { revalidatePath, revalidateTag } from 'next/cache';
revalidateTag('posts');              // revalidate all fetches tagged 'posts'
revalidatePath('/posts');            // revalidate the /posts page
revalidatePath('/posts/[id]', 'page'); // revalidate all /posts/* pages

Breaking change: Next.js 14 β†’ 15. In Next.js 14 and earlier, fetch was force-cache (cached) by default. Next.js 15 changed this to no-store (uncached). Auditing existing 14β†’15 migrations requires checking every fetch call.

Route Handlers (API Routes)

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page  = parseInt(searchParams.get('page') ?? '1');
  const limit = parseInt(searchParams.get('limit') ?? '20');

  const users = await db.getUsers({ page, limit });
  return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
  const body = await request.json();

  // Validate body
  const parsed = createUserSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const user = await db.createUser(parsed.data);
  return NextResponse.json(user, { status: 201 });
}

// Dynamic route: app/api/users/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await db.getUserById(params.id);
  if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 });
  return NextResponse.json(user);
}

Server Actions β€” Forms Without APIs

Server Actions let you run server-side code directly from a form or Client Component β€” no API route needed.

// app/posts/new/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect }       from 'next/navigation';

export async function createPost(formData: FormData) {
  const title   = formData.get('title') as string;
  const content = formData.get('content') as string;

  // Input validation on the server
  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' };
  }

  const post = await db.createPost({ title, content });
  revalidatePath('/posts');           // clear the /posts page cache
  redirect(`/posts/${post.id}`);      // navigate to the new post
}

// app/posts/new/page.tsx β€” use the action directly in the form
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" />
      <textarea name="content" placeholder="Write your post..." />
      <button type="submit">Publish</button>
    </form>
  );
}

Middleware

Runs at the Edge before the request reaches any page or API route. Ultra-fast β€” no cold start, no Node.js runtime.

// middleware.ts β€” must be at the project root
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Auth guard β€” redirect to login if no session
  const token = request.cookies.get('session')?.value;
  if (!token && pathname.startsWith('/dashboard')) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Rewrite β€” serve /new-path from /old-path internally
  if (pathname === '/old-path') {
    return NextResponse.rewrite(new URL('/new-path', request.url));
  }

  // Add custom response headers
  const response = NextResponse.next();
  response.headers.set('X-Request-Id', crypto.randomUUID());
  return response;
}

export const config = {
  // Only run middleware on these paths β€” avoids running on _next/static, images, etc.
  matcher: ['/dashboard/:path*', '/api/:path*', '/old-path'],
};

Authentication

The standard approach for Next.js is NextAuth.js (Auth.js):

npm install next-auth
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';

const handler = NextAuth({
  providers: [
    GitHub({
      clientId:     process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Credentials({
      credentials: {
        email:    { label: 'Email',    type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        const user = await db.findUserByEmail(credentials.email);
        if (!user || !await bcrypt.compare(credentials.password, user.passwordHash)) {
          return null; // null = invalid credentials
        }
        return { id: user.id, name: user.name, email: user.email };
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) token.role = user.role;   // add custom fields to the JWT
      return token;
    },
    async session({ session, token }) {
      session.user.role = token.role;     // expose role to the client session
      return session;
    },
  },
});

export { handler as GET, handler as POST };

πŸ”΄ Senior

Caching Architecture

Next.js has four layers of caching that compose together:

Layer What it caches Duration
Request Memoization fetch results within one render Single render pass
Data Cache fetch results stored on disk Until revalidate / manual invalidation
Full Route Cache Rendered HTML + RSC payload Until rebuild / revalidation
Router Cache Client-side route segment cache Duration of browser session

Understanding these layers is critical for debugging β€œwhy is my data stale?”:

// This fetch is memoized β€” called twice in one render, hits network once
async function getUser(id: string) {
  return fetch(`/api/users/${id}`, { cache: 'force-cache' });
}

// Opt a whole segment into dynamic rendering (disables Full Route Cache)
export const dynamic = 'force-dynamic';   // page-level
export const revalidate = 0;              // alternative β€” same effect

// Granular: mix static + dynamic in the same page using Suspense
export default async function DashboardPage() {
  return (
    <>
      <StaticSidebar />          {/* rendered at build time */}
      <Suspense fallback={<Loading />}>
        <DynamicFeed />          {/* streamed when ready */}
      </Suspense>
    </>
  );
}

Streaming with Suspense

Streaming lets you send HTML progressively β€” the shell renders first, slower parts stream in.

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

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Fast β€” renders immediately, no suspend */}
      <QuickStats />

      {/* Slow β€” shows skeleton while fetching */}
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />        {/* async Server Component β€” streams when ready */}
      </Suspense>

      {/* Another slow section β€” streams independently */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </div>
  );
}

// components/DataTable.tsx β€” async Server Component
async function DataTable() {
  const data = await db.getSlowReport();   // this blocks only this Suspense boundary
  return <table>...</table>;
}

Without streaming, the entire page waits for the slowest component. With streaming, users see content progressively.

App Router vs Pages Router

Β  App Router Pages Router
Introduced Next.js 13 (stable: 13.4) Next.js 1.0
Default component type Server Components Client Components
Layouts Nested, persistent _app.tsx only (re-renders on navigation)
Data fetching fetch with cache control in any async component getServerSideProps / getStaticProps in page files
Streaming Built-in (Suspense) Not supported
Server Actions Yes No
Migration Possible to run both simultaneously β€”

For new projects, always use the App Router. For existing Pages Router projects, both can coexist during migration.

Performance & Core Web Vitals

// Measure Core Web Vitals
export function reportWebVitals(metric) {
  console.log(metric); // { name: 'LCP', value: 1200, ... }
  analytics.send({ name: metric.name, value: metric.value });
}

// next.config.js β€” performance tuning
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ['cdn.example.com'],        // allow external image optimization
    formats: ['image/avif', 'image/webp'],
  },
  experimental: {
    optimizePackageImports: ['lodash', 'date-fns'], // tree-shake large packages
  },
  // Bundle analyzer
  // ANALYZE=true npm run build β†’ visual breakdown of bundle
};

LCP (Largest Contentful Paint):

  • Add priority prop to above-the-fold <Image> components β€” preloads them
  • Use next/font β€” eliminates font network request
  • Prefer loading="eager" on hero images

CLS (Cumulative Layout Shift):

  • Always set width and height on <Image> β€” reserves space before load
  • Use font-display: optional via next/font

FID / INP (Interaction to Next Paint):

  • Minimize Client Component surface area
  • Use useTransition for non-urgent state updates that would block input

Deployment Options

# Vercel β€” zero config, recommended (made by the Next.js team)
vercel deploy

# Self-hosted Node.js β€” requires a Node.js process
next build && next start -p 3000

# Docker β€” standalone output bundles everything needed
# next.config.js:
output: 'standalone'  # creates .next/standalone β€” no node_modules needed at runtime

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

# Static export β€” no Node.js server, pure static files (no SSR/ISR/Middleware)
# next.config.js:
output: 'export'
# Then deploy dist/ to any CDN (Netlify, S3+CloudFront, GitHub Pages)

Senior Gotchas

  • 'use client' does NOT mean β€œbrowser only” β€” Client Components are still SSR’d on the initial page load and hydrated on the client. They run on both server (first load) and client (navigations). β€œuse client” means β€œthis is the boundary between server and client trees,” not β€œskip SSR.”
  • searchParams opts into dynamic rendering β€” any Server Component that reads searchParams or headers() becomes dynamic (no Full Route Cache). Be deliberate about which components access these.
  • Middleware runs on the Edge runtime β€” no Node.js APIs available (fs, native modules, node: imports). Keep middleware lightweight.
  • Server Actions are POST requests β€” always validate input server-side. They are not protected by middleware unless you add an auth check inside the action.
  • React deduplicates identical fetch calls β€” two identical fetch(url) calls in the same render tree hit the network only once (request memoization). This lets you call data-fetching functions freely without worrying about waterfalls.
  • revalidatePath vs revalidateTag β€” revalidatePath clears the Full Route Cache for a specific URL. revalidateTag clears all Data Cache entries with that tag, across any number of routes. Use tags for content-type-based invalidation.
  • loading.tsx is Suspense β€” it creates an automatic Suspense boundary around the page. If you add your own <Suspense> inside the page, nested layouts wrap it.
  • Environment variables are baked in at build time β€” NEXT_PUBLIC_* variables are exposed to the browser and embedded in the JS bundle. Never put secrets in NEXT_PUBLIC_*. Server-only env vars (without the prefix) are never sent to the client.