Next.js has become the default choice for production React applications. With the App Router and React Server Components, it's evolved far beyond a simple SSR framework—and interviews reflect this complexity.
This guide covers the Next.js concepts that interviewers actually ask about, from fundamental routing to advanced rendering strategies and Server Actions.
1. Next.js Fundamentals
Understanding the core architecture is essential for any Next.js interview.
App Router vs Pages Router
What are the key differences between App Router and Pages Router?
Project Structure Comparison:
Pages Router (Legacy) App Router (Modern)
pages/ app/
├── _app.js ├── layout.js
├── _document.js ├── page.js
├── index.js ├── loading.js
├── about.js ├── error.js
├── blog/ ├── about/
│ ├── index.js │ └── page.js
│ └── [slug].js └── blog/
└── api/ ├── page.js
└── users.js └── [slug]/
└── page.js
| Feature | Pages Router | App Router |
|---|---|---|
| Default components | Client Components | Server Components |
| Data fetching | getServerSideProps, getStaticProps | Native fetch in components |
| Layouts | Manual with _app.js | Nested layouts per route |
| Loading states | Manual implementation | loading.js convention |
| Error handling | _error.js (global) | error.js (per route) |
| Streaming | Limited | Full support |
When to use each:
- App Router: New projects, when you need Server Components, complex layouts, streaming
- Pages Router: Legacy projects, gradual migration, specific Pages Router features
File-Based Routing
How does file-based routing work in App Router?
app/
├── page.js → /
├── about/
│ └── page.js → /about
├── blog/
│ ├── page.js → /blog
│ └── [slug]/
│ └── page.js → /blog/:slug
├── shop/
│ └── [...categories]/
│ └── page.js → /shop/*, /shop/a/b/c
└── (marketing)/ → Route group (no URL impact)
├── about/
│ └── page.js → /about
└── contact/
└── page.js → /contact
Special files in App Router:
| File | Purpose |
|---|---|
page.js | UI for a route (makes route accessible) |
layout.js | Shared UI wrapper, persists across navigations |
loading.js | Loading UI (Suspense boundary) |
error.js | Error UI (Error boundary) |
not-found.js | 404 UI |
route.js | API endpoint (replaces pages/api) |
template.js | Like layout but re-renders on navigation |
Layouts and Templates
How do layouts work and why are they important?
// app/layout.js - Root layout (required)
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header>
<nav>{/* Navigation persists across all pages */}</nav>
</header>
<main>{children}</main>
<footer>{/* Footer persists */}</footer>
</body>
</html>
);
}
// app/dashboard/layout.js - Nested layout
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<aside>
<DashboardNav /> {/* Sidebar persists within dashboard */}
</aside>
<section>{children}</section>
</div>
);
}
// Result: /dashboard/settings renders:
// RootLayout > DashboardLayout > SettingsPageKey layout behaviors:
- Layouts don't re-render on navigation (state preserved)
- Layouts can fetch data (Server Components by default)
- Nested layouts wrap nested routes automatically
- Root layout is required and must include
<html>and<body>
2. Server Components vs Client Components
React Server Components (RSC) are the foundation of App Router.
Mental Model
┌─────────────────────────────────────────────────────────────┐
│ SERVER │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Server Components │ │
│ │ - Fetch data directly │ │
│ │ - Access backend resources │ │
│ │ - Keep secrets on server │ │
│ │ - Zero JavaScript sent to client │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ HTML + RSC Payload
▼
┌─────────────────────────────────────────────────────────────┐
│ CLIENT │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Client Components │ │
│ │ - Interactivity (onClick, onChange) │ │
│ │ - useState, useEffect, useContext │ │
│ │ - Browser APIs (localStorage, geolocation) │ │
│ │ - Third-party libraries with client dependencies │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
When to Use Each
// Server Component (default) - NO "use client" directive
// app/products/page.js
import { db } from '@/lib/db';
export default async function ProductsPage() {
// ✅ Direct database access
const products = await db.products.findMany();
// ✅ Can use async/await at component level
// ✅ This code never reaches the client
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// Client Component - HAS "use client" directive
// components/add-to-cart-button.js
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }) {
const [isAdding, setIsAdding] = useState(false);
// ✅ Can use hooks
// ✅ Can use event handlers
async function handleClick() {
setIsAdding(true);
await addToCart(productId);
setIsAdding(false);
}
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}Composition Patterns
How do you combine Server and Client Components?
// ✅ CORRECT: Pass Server Component as children to Client Component
// app/page.js (Server Component)
import { ClientWrapper } from './client-wrapper';
import { ServerContent } from './server-content';
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* Server Component passed as children */}
</ClientWrapper>
);
}
// client-wrapper.js
'use client';
export function ClientWrapper({ children }) {
const [isOpen, setIsOpen] = useState(true);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* Server-rendered content */}
</div>
);
}
// ❌ WRONG: Importing Server Component into Client Component
'use client';
import { ServerContent } from './server-content'; // This won't work as expected!
export function ClientWrapper() {
return <ServerContent />; // ServerContent becomes a Client Component
}Key rule: Client Components can render Server Components only when passed as children or props, not when imported directly.
Serialization Boundary
What can you pass from Server to Client Components?
// ✅ Serializable props (can cross the boundary)
<ClientComponent
string="hello"
number={42}
boolean={true}
array={[1, 2, 3]}
object={{ name: 'John' }}
date={new Date().toISOString()} // Convert to string
/>
// ❌ Non-serializable (cannot cross the boundary)
<ClientComponent
function={() => console.log('hi')} // Functions don't serialize
class={new MyClass()} // Class instances don't serialize
symbol={Symbol('test')} // Symbols don't serialize
map={new Map()} // Map/Set need conversion
/>
// ✅ Solution: Pass data, define functions in Client Component
// Server Component
<ClientComponent userId={user.id} />
// Client Component
'use client';
export function ClientComponent({ userId }) {
// Define the function here, on the client
const handleClick = () => deleteUser(userId);
return <button onClick={handleClick}>Delete</button>;
}3. Data Fetching
App Router simplifies data fetching with native fetch.
Server-Side Fetching
// app/posts/page.js
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
// Caching options
cache: 'force-cache', // Default: static (cached indefinitely)
// cache: 'no-store', // Dynamic: fetch on every request
// next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Caching Strategies
| Strategy | Fetch Option | Use Case |
|---|---|---|
| Static | cache: 'force-cache' (default) | Content that rarely changes |
| Dynamic | cache: 'no-store' | User-specific data, real-time data |
| ISR | next: { revalidate: 60 } | Content that updates periodically |
| On-demand | revalidateTag() / revalidatePath() | Revalidate after mutations |
// Time-based revalidation (ISR)
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // Revalidate every hour
});
return res.json();
}
// Tag-based revalidation
async function getProduct(id) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: ['products', `product-${id}`] },
});
return res.json();
}
// In a Server Action, revalidate by tag
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id, data) {
await db.products.update(id, data);
revalidateTag(`product-${id}`); // Invalidate specific product
revalidateTag('products'); // Invalidate product list
}Request Deduplication
// Next.js automatically deduplicates identical fetch requests
// in a single render pass
async function getUser(id) {
// This fetch is automatically cached and deduplicated
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json();
}
// Layout fetches user
async function Layout({ children }) {
const user = await getUser(1); // Fetch #1
return <div><UserNav user={user} />{children}</div>;
}
// Page also fetches same user
async function Page() {
const user = await getUser(1); // Same request - deduplicated!
return <UserProfile user={user} />;
}
// Result: Only ONE network request is madeParallel vs Sequential Fetching
// ❌ Sequential (slow) - each fetch waits for previous
async function Page() {
const user = await getUser(); // Wait...
const posts = await getPosts(); // Then wait...
const comments = await getComments(); // Then wait...
return <div>...</div>;
}
// ✅ Parallel (fast) - all fetches start simultaneously
async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
return <div>...</div>;
}
// ✅ Even better: Parallel with Suspense (streaming)
function Page() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<User /> {/* Fetches independently */}
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<Posts /> {/* Fetches independently */}
</Suspense>
</div>
);
}4. Rendering Strategies
Next.js offers multiple rendering approaches for different use cases.
Static Rendering (Default)
// Pages are static by default if they don't use dynamic features
// app/about/page.js
export default function AboutPage() {
return <div>About us - this is statically rendered at build time</div>;
}
// Static with data
async function getContent() {
const res = await fetch('https://cms.example.com/about', {
cache: 'force-cache', // Static
});
return res.json();
}
export default async function AboutPage() {
const content = await getContent();
return <div>{content.body}</div>;
}Dynamic Rendering
// Triggers dynamic rendering:
// 1. Using cache: 'no-store'
// 2. Using cookies() or headers()
// 3. Using searchParams prop
// 4. Using dynamic = 'force-dynamic'
import { cookies, headers } from 'next/headers';
export default async function DashboardPage() {
// Accessing cookies makes this dynamic
const cookieStore = cookies();
const token = cookieStore.get('session');
// Accessing headers makes this dynamic
const headersList = headers();
const userAgent = headersList.get('user-agent');
const data = await fetch('https://api.example.com/dashboard', {
cache: 'no-store', // Also makes it dynamic
headers: { Authorization: `Bearer ${token}` },
});
return <div>...</div>;
}
// Force dynamic rendering
export const dynamic = 'force-dynamic';Incremental Static Regeneration (ISR)
// app/blog/[slug]/page.js
async function getPost(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 }, // Revalidate every 60 seconds
});
return res.json();
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <article>{post.content}</article>;
}
// Generate static params at build time
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map(post => ({
slug: post.slug,
}));
}
// Behavior:
// 1. Build time: Generate pages for all returned slugs
// 2. Request for uncached slug: Generate on-demand, cache it
// 3. After 60s: Next request serves stale, triggers regenerationStreaming with Suspense
// app/dashboard/page.js
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast: Shows immediately */}
<WelcomeMessage />
{/* Slow: Streams in when ready */}
<Suspense fallback={<ChartSkeleton />}>
<SlowChart /> {/* Async component */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<SlowDataTable /> {/* Another async component */}
</Suspense>
</div>
);
}
// SlowChart.js - Async Server Component
async function SlowChart() {
const data = await fetchAnalytics(); // Slow API call
return <Chart data={data} />;
}Streaming Timeline:
Time 0ms: [Welcome Message] [Loading...] [Loading...]
Time 500ms: [Welcome Message] [ Chart ] [Loading...]
Time 1200ms: [Welcome Message] [ Chart ] [ Table ]
User sees content progressively instead of waiting for everything
5. Routing & Navigation
App Router provides powerful routing capabilities.
Dynamic Routes
// app/blog/[slug]/page.js - Single dynamic segment
export default function BlogPost({ params }) {
return <div>Post: {params.slug}</div>;
}
// /blog/hello-world → params.slug = 'hello-world'
// app/shop/[...categories]/page.js - Catch-all
export default function Category({ params }) {
return <div>Categories: {params.categories.join(' > ')}</div>;
}
// /shop/electronics/phones → params.categories = ['electronics', 'phones']
// app/docs/[[...slug]]/page.js - Optional catch-all
export default function Docs({ params }) {
return <div>Slug: {params.slug?.join('/') || 'index'}</div>;
}
// /docs → params.slug = undefined
// /docs/api/auth → params.slug = ['api', 'auth']Route Groups
// Route groups organize without affecting URL
app/
├── (marketing)/ // Group: marketing pages
│ ├── layout.js // Shared marketing layout
│ ├── about/
│ │ └── page.js // → /about
│ └── contact/
│ └── page.js // → /contact
├── (shop)/ // Group: shop pages
│ ├── layout.js // Shared shop layout (different from marketing)
│ └── products/
│ └── page.js // → /products
└── (auth)/ // Group: auth pages
├── layout.js // Minimal auth layout
├── login/
│ └── page.js // → /login
└── register/
└── page.js // → /registerParallel Routes
// Render multiple pages simultaneously in the same layout
app/
├── layout.js
├── page.js
├── @analytics/ // Parallel route (slot)
│ └── page.js
└── @team/ // Another parallel route
└── page.js
// app/layout.js
export default function Layout({ children, analytics, team }) {
return (
<div>
<main>{children}</main>
<aside>
{analytics}
{team}
</aside>
</div>
);
}Intercepting Routes
// Intercept routes to show modals while preserving URL
app/
├── feed/
│ └── page.js // Main feed
├── photo/
│ └── [id]/
│ └── page.js // Full photo page (direct navigation)
└── @modal/
└── (.)photo/ // (.) = intercept same level
└── [id]/
└── page.js // Photo modal (soft navigation)
// Clicking photo in feed: Shows modal at /photo/123
// Direct URL /photo/123: Shows full page
// Refresh on /photo/123: Shows full pageNavigation
// Link component (preferred for most navigation)
import Link from 'next/link';
export function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog/hello-world">Blog Post</Link>
{/* Prefetching control */}
<Link href="/heavy-page" prefetch={false}>
Heavy Page (no prefetch)
</Link>
{/* Replace history instead of push */}
<Link href="/login" replace>
Login
</Link>
</nav>
);
}
// useRouter for programmatic navigation
'use client';
import { useRouter } from 'next/navigation';
export function LoginButton() {
const router = useRouter();
async function handleLogin() {
const success = await login();
if (success) {
router.push('/dashboard'); // Navigate
// router.replace('/dashboard'); // Navigate without history entry
// router.refresh(); // Refresh server components
// router.back(); // Go back
// router.forward(); // Go forward
}
}
return <button onClick={handleLogin}>Login</button>;
}6. Server Actions & Mutations
Server Actions handle data mutations without API routes.
Basic Server Actions
// app/actions.js
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// Validate
if (!title || !content) {
return { error: 'Title and content are required' };
}
// Create in database
const post = await db.posts.create({
data: { title, content },
});
// Revalidate the posts list
revalidatePath('/posts');
// Redirect to new post
redirect(`/posts/${post.id}`);
}
// app/posts/new/page.js
import { createPost } from '../actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}Server Actions with useFormState
// app/actions.js
'use server';
export async function createUser(prevState, formData) {
const email = formData.get('email');
// Validation
if (!email.includes('@')) {
return { error: 'Invalid email address' };
}
// Check if exists
const existing = await db.users.findByEmail(email);
if (existing) {
return { error: 'Email already registered' };
}
// Create user
await db.users.create({ email });
return { success: true, message: 'User created!' };
}
// app/register/page.js
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createUser } from '../actions';
const initialState = { error: null, success: false };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Account'}
</button>
);
}
export default function RegisterPage() {
const [state, formAction] = useFormState(createUser, initialState);
return (
<form action={formAction}>
<input name="email" type="email" placeholder="Email" />
{state.error && (
<p className="error">{state.error}</p>
)}
{state.success && (
<p className="success">{state.message}</p>
)}
<SubmitButton />
</form>
);
}Optimistic Updates
'use client';
import { useOptimistic } from 'react';
import { likePost } from './actions';
export function LikeButton({ postId, initialLikes }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, newLike) => state + 1
);
async function handleLike() {
addOptimisticLike(1); // Immediately update UI
await likePost(postId); // Server action (may take time)
}
return (
<form action={handleLike}>
<button type="submit">
❤️ {optimisticLikes}
</button>
</form>
);
}Server Actions vs API Routes
| Feature | Server Actions | API Routes |
|---|---|---|
| Use case | Form submissions, mutations | External APIs, webhooks |
| Invocation | Direct function call | HTTP request |
| Progressive enhancement | Built-in (works without JS) | Manual |
| Type safety | End-to-end with TypeScript | Request/response typing |
| Caching integration | revalidatePath/revalidateTag | Manual |
// When to use API Routes (route.js)
// - External webhook endpoints
// - Third-party API integration
// - When you need specific HTTP methods/headers
// - Public API for other consumers
// app/api/webhook/route.js
export async function POST(request) {
const payload = await request.json();
const signature = request.headers.get('x-webhook-signature');
if (!verifySignature(payload, signature)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
await processWebhook(payload);
return Response.json({ received: true });
}7. Performance & Optimization
Image Optimization
import Image from 'next/image';
export function ProductImage({ product }) {
return (
<Image
src={product.imageUrl}
alt={product.name}
width={800}
height={600}
// Priority for above-the-fold images (disables lazy loading)
priority={true}
// Placeholder while loading
placeholder="blur"
blurDataURL={product.blurDataUrl} // Base64 or use static import
// Responsive sizing
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
// Fill parent container
// fill={true}
// style={{ objectFit: 'cover' }}
/>
);
}
// For dynamic images without known dimensions
export function Avatar({ user }) {
return (
<div className="avatar-wrapper" style={{ position: 'relative', width: 50, height: 50 }}>
<Image
src={user.avatarUrl}
alt={user.name}
fill
sizes="50px"
style={{ objectFit: 'cover', borderRadius: '50%' }}
/>
</div>
);
}Font Optimization
// app/layout.js
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body className={inter.className}>
{children}
</body>
</html>
);
}
// Local fonts
import localFont from 'next/font/local';
const myFont = localFont({
src: './my-font.woff2',
display: 'swap',
});Metadata API
// app/layout.js - Static metadata
export const metadata = {
title: {
template: '%s | My Site',
default: 'My Site',
},
description: 'My awesome website',
openGraph: {
title: 'My Site',
description: 'My awesome website',
images: ['/og-image.jpg'],
},
};
// app/blog/[slug]/page.js - Dynamic metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}Bundle Analysis
# Install bundle analyzer
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// Next.js config
});
# Run analysis
ANALYZE=true npm run buildCommon Performance Patterns
// 1. Dynamic imports for heavy components
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Only load on client (for browser-only libraries)
});
// 2. Route segment config for caching
export const revalidate = 3600; // Page-level ISR
export const dynamic = 'force-static'; // Force static
export const fetchCache = 'force-cache'; // Cache all fetches
// 3. Parallel data fetching
async function Page() {
// Start all fetches immediately
const userPromise = getUser();
const postsPromise = getPosts();
const statsPromise = getStats();
// Await all together
const [user, posts, stats] = await Promise.all([
userPromise,
postsPromise,
statsPromise,
]);
return <Dashboard user={user} posts={posts} stats={stats} />;
}Quick Reference: Common Interview Questions
| Topic | Key Points |
|---|---|
| App vs Pages Router | Server Components, nested layouts, native fetch vs getServerSideProps |
| Server Components | Default in App Router, zero client JS, direct data fetching |
| Client Components | "use client", interactivity, hooks, browser APIs |
| Data Fetching | Native fetch, cache options, revalidation, deduplication |
| Rendering | Static (default), Dynamic (no-store, cookies), ISR (revalidate), Streaming |
| Server Actions | "use server", form handling, revalidation, progressive enhancement |
| Caching | fetch cache, revalidatePath, revalidateTag, request deduplication |
| Optimization | Image component, font optimization, Metadata API, dynamic imports |
Related Articles
- React Hooks Interview Guide - React fundamentals
- React Advanced Interview Guide - Advanced React patterns
- React 19 New Features Interview Guide - Latest React features
- TypeScript Generics Interview Guide - Type-safe Next.js development
- Complete Frontend Developer Interview Guide - Full frontend preparation
