Key features of Next.js
Next.js is a React framework that lets you mix server-side rendering (SSR), static site generation (SSG), incremental static regeneration (ISR), and client-side rendering (CSR) per page, so each route gets exactly the rendering strategy its data needs.
Theory
TL;DR
- SSG generates HTML at build time and serves it from CDN. Fastest option, but data freezes until the next build.
- SSR renders HTML on the server per request. Always fresh, but adds server latency.
- ISR is the middle ground: static HTML rebuilt in the background on a schedule.
- App Router (v13+) replaces
getStaticProps/getServerSidePropswith fetch options on async components. - All components are Server Components by default. Add
'use client'only where you need state or browser APIs.
Hybrid rendering
The core idea: one app, multiple rendering strategies. You pick per route.
// SSG - HTML generated once at build, served from CDN
async function BlogPage() {
const res = await fetch('https://api.itlead.org/posts', {
cache: 'force-cache' // default behavior in App Router
});
const posts = await res.json();
return <PostList posts={posts} />;
}
// ISR - static at build, rebuilds in background every 5 minutes
export const revalidate = 300;
async function ProblemsPage() {
const res = await fetch('https://api.itlead.org/problems');
const problems = await res.json();
return <ProblemList problems={problems} />;
}
// SSR - fresh HTML per request
async function DashboardPage() {
const res = await fetch('https://api.itlead.org/user/stats', {
cache: 'no-store'
});
const stats = await res.json();
return <StatsPanel stats={stats} />;
}The decision rule is simpler than it sounds: SSG for anything that looks the same to every visitor, SSR for anything personalized or real-time, ISR for content that changes but not every second.
In practice, the biggest shift coming from Create React App is unlearning the idea of one rendering mode for the whole project.
App Router and file-based routing
Next.js 13 introduced the App Router. The folder structure inside app/ directly maps to routes. Special files handle specific concerns without any configuration:
| File | Purpose |
|---|---|
page.tsx | Route UI, makes the segment publicly accessible |
layout.tsx | Persistent wrapper across child navigations |
loading.tsx | Suspense-based loading UI |
error.tsx | Error boundary for the segment |
not-found.tsx | 404 UI |
// app/docs/[slug]/page.tsx
// Handles: /docs/javascript, /docs/react, /docs/nextjs
export default async function DocPage({
params
}: {
params: { slug: string }
}) {
const doc = await getDocument(params.slug);
return <article>{doc.content}</article>;
}The key shift from Pages Router: getStaticProps and getServerSideProps are gone. The fetch options inside async Server Components replace them. Less boilerplate, same control.
Server Components
Every component in the App Router is a Server Component by default. That means its code runs on the server, never ships to the browser, and can access databases, environment variables, and the file system directly.
// app/stats/page.tsx - no client bundle impact at all
import { db } from '@/lib/db';
export default async function StatsPage() {
const totalUsers = await db.user.count();
const totalProblems = await db.problem.count();
return (
<div>
<p>Users: {totalUsers}</p>
<p>Problems: {totalProblems}</p>
</div>
);
}When you need interactivity - state, event handlers, browser APIs - add 'use client' at the top. The pattern that works well in production: keep data fetching in Server Components, push interactive logic down into small Client Components beneath them.
'use client'
import { useState } from 'react'
export default function ThemeToggle() {
const [dark, setDark] = useState(false)
return (
<button onClick={() => setDark(!dark)}>
{dark ? 'Light mode' : 'Dark mode'}
</button>
)
}Server Actions
Server Actions let you call server-side functions from Client Components without writing a separate API route. Mark the function with 'use server' and call it like any other function.
// actions/subscribe.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function subscribe(email: string) {
await db.subscriber.create({ data: { email } })
revalidatePath('/newsletter') // update the cached page
}// Client component - no /api/subscribe route needed
'use client'
import { subscribe } from '@/actions/subscribe'
export default function SubscribeForm() {
return (
<form action={async (formData) => {
const email = formData.get('email') as string
await subscribe(email)
}}>
<input name="email" type="email" placeholder="Email" />
<button type="submit">Subscribe</button>
</form>
)
}Under the hood, Next.js turns the Server Action into a secure POST request. You get type safety across the client-server boundary without any manual API wiring.
Nested Layouts
Layouts in the App Router persist across navigations. The layout component mounts once and stays mounted while users navigate between child routes.
// app/docs/layout.tsx - wraps all /docs/* routes
export default function DocsLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<Sidebar /> {/* stays mounted, keeps scroll position */}
<main className="flex-1">{children}</main>
</div>
)
}Going from /docs/javascript to /docs/react? The sidebar does not remount. Scroll position, open sections, local state in the layout all survive. Only children re-renders. That is the whole point of nested layouts.
Built-in optimizations
Three components handle most performance concerns out of the box.
next/image resizes images, converts to WebP or AVIF, and handles lazy loading automatically. You provide dimensions, it does the rest.
import Image from 'next/image'
export default function Avatar() {
return (
<Image src="/avatar.png" width={64} height={64} alt="User avatar" />
)
}next/link prefetches pages in the background when a link enters the viewport. By the time a user clicks, the page data is already loaded.
import Link from 'next/link'
export default function Nav() {
return (
<nav>
<Link href="/problems">Problems</Link>
<Link href="/docs">Documentation</Link>
</nav>
)
}next/font downloads Google Fonts at build time and self-hosts them. No client-side requests to Google servers, no layout shift from late-loading fonts.
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin', 'cyrillic'] })
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}Middleware
Middleware runs at the Edge before a request hits your application. You can redirect, rewrite URLs, or modify headers. The Edge Runtime is not Node.js - fs, path, and other Node-only APIs are not available here.
// middleware.ts (project root)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const locale = request.cookies.get('locale')?.value || 'en'
if (!request.nextUrl.pathname.startsWith(`/${locale}`)) {
return NextResponse.redirect(
new URL(`/${locale}${request.nextUrl.pathname}`, request.url)
)
}
}
export const config = {
matcher: ['/((?!api|_next|favicon.*))']
}Common uses: authentication guards, locale detection, A/B testing redirects, security header injection.
Metadata API
Next.js has a built-in API for SEO metadata. Export a metadata object from any page.tsx or layout.tsx:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'JavaScript Problems — IT Lead',
description: 'Solve problems from real frontend interviews',
openGraph: {
title: 'JavaScript Problems',
description: 'Solve problems from real frontend interviews',
type: 'website'
}
}
export default function ProblemsPage() {
return <ProblemsList />
}For per-item metadata (blog posts, product pages), export generateMetadata as an async function instead. It receives the route params and can fetch data to build the title and description dynamically.
Common mistakes
Forgetting cache: 'no-store' on SSR pages. The App Router caches fetch calls by default. A user dashboard that calls fetch('/api/user') without options caches the response at build time and serves the same stale data to everyone.
// Wrong - caches like SSG, serves stale user data to every visitor
const data = await fetch('/api/user');
// Correct - fresh data per request
const data = await fetch('/api/user', { cache: 'no-store' });Putting 'use client' at the top of a page that only needs one interactive element. This forces full CSR on the entire page and removes server-rendered HTML. Move state and event handlers into small child Client Components instead.
// Wrong - whole page becomes CSR, SEO gone
'use client'
export default async function Page() { ... }
// Correct - page is server-rendered, interactivity is isolated
export default async function Page() {
const data = await fetchData();
return <InteractiveChild data={data} />; // 'use client' lives inside here
}Thinking revalidate: 0 enables SSR. Setting export const revalidate = 0 does not make a route server-rendered. The page is still static at build. For actual per-request rendering use export const dynamic = 'force-dynamic' or add cache: 'no-store' to all fetch calls.
Skipping <Suspense> around streaming components. Without a Suspense boundary, an async Server Component blocks the entire page from sending any HTML until it resolves. Wrap slow components in <Suspense fallback={<Loader />}>.
Real-world usage
- Vercel.com: SSG for documentation (global CDN delivery), SSR for the authenticated dashboard.
- Hashnode: SSG for blog posts, SSR for comment sections and user-specific feeds.
- E-commerce stores: ISR for product pages with
revalidate: 60, so prices update without a full rebuild. - Admin panels: CSR with
'use client'throughout, since SEO is irrelevant and data changes on every action.
Follow-up questions
Q: What is the difference between cache: 'no-store' and export const dynamic = 'force-dynamic'?
A: cache: 'no-store' applies to a single fetch call and opts that one request out of caching. dynamic = 'force-dynamic' marks the entire route as dynamic and cascades to all child components, overriding any route-level caching.
Q: How does Turbopack affect build times?
A: Turbopack replaces Webpack as the bundler in development. Next.js 14 reports around 700x faster hot module replacement in dev mode. Production builds still use Webpack as of Next.js 14.
Q: What is an RSC payload and how does it relate to streaming?
A: React Server Components serialize their output as an RSC payload, not raw HTML. The client receives a static HTML shell first, then the RSC payload streams in to hydrate interactive parts. Wrapping slow components in <Suspense> sends the shell immediately and streams the slow parts after they resolve.
Q: When does a hydration mismatch happen and how do you fix it?
A: When the server-rendered HTML does not match what React expects to render on the client. Common causes: timestamps, random IDs, or browser-only APIs called during render. Fix with useEffect to defer client-only code, or suppressHydrationWarning on the element for harmless differences.
Q: In a high-traffic app, how would you invalidate ISR cache across multiple regions?
A: Use revalidateTag or revalidatePath triggered by a webhook when content changes. Pair with Upstash Redis as a shared cache store across Edge regions. For paths where any stale data is unacceptable, switch to SSR with cache: 'no-store'.
Examples
ISR product page
A product page that rebuilds in the background every 60 seconds. Users always get a valid cached response. The update happens without any deploy.
// app/products/[id]/page.tsx
interface Product {
id: string;
name: string;
price: number;
}
export const revalidate = 60;
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product: Product = await fetch(
`https://api.example.com/products/${params.id}`,
{ next: { revalidate: 60 } }
).then(res => res.json());
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
</div>
);
}
// Static HTML from CDN, product data updated in background - no rebuild neededStreaming dashboard with Suspense
The header renders instantly. The analytics panel streams in after its data resolves. Without <Suspense>, the entire page waits for the slowest component to finish.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import Analytics from './analytics'
export default function Dashboard() {
return (
<>
<Header /> {/* visible at ~50ms */}
<Suspense fallback={<div>Loading analytics...</div>}>
<Analytics /> {/* streams in at ~2s */}
</Suspense>
</>
)
}
// app/dashboard/analytics.tsx
export default async function Analytics() {
const data = await fetch('https://api.itlead.org/analytics', {
cache: 'no-store'
}).then(r => r.json());
return <Chart data={data} />;
}
// No full-page spinner. Users see the shell while analytics load.Server Action with cache invalidation
Form submission writes to the database and invalidates the relevant cached route. No separate API file needed.
// actions/solution.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function submitSolution(formData: FormData) {
const code = formData.get('code') as string
const problemId = formData.get('problemId') as string
await db.solution.create({ data: { code, problemId } })
revalidatePath(`/problems/${problemId}`) // invalidate cached problem page
}// app/problems/[id]/submit.tsx
'use client'
import { submitSolution } from '@/actions/solution'
export default function SubmitForm({ problemId }: { problemId: string }) {
return (
<form action={submitSolution}>
<input type="hidden" name="problemId" value={problemId} />
<textarea name="code" placeholder="Your solution..." rows={10} />
<button type="submit">Submit solution</button>
</form>
)
}
// Form posts to server action, writes to DB, refreshes the cached problem pageShort Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.