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,
fetchwasforce-cache(cached) by default. Next.js 15 changed this tono-store(uncached). Auditing existing 14β15 migrations requires checking everyfetchcall.
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
priorityprop 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
widthandheighton<Image>β reserves space before load - Use
font-display: optionalvianext/font
FID / INP (Interaction to Next Paint):
- Minimize Client Component surface area
- Use
useTransitionfor 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.βsearchParamsopts into dynamic rendering β any Server Component that readssearchParamsorheaders()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
fetchcalls β two identicalfetch(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. revalidatePathvsrevalidateTagβrevalidatePathclears the Full Route Cache for a specific URL.revalidateTagclears all Data Cache entries with that tag, across any number of routes. Use tags for content-type-based invalidation.loading.tsxis 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 inNEXT_PUBLIC_*. Server-only env vars (without the prefix) are never sent to the client.