Next.js Interview Guide: App Router, Server Components & Modern React

·18 min read
nextjsreactfrontendserver-componentsjavascriptinterview-preparation

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
FeaturePages RouterApp Router
Default componentsClient ComponentsServer Components
Data fetchinggetServerSideProps, getStaticPropsNative fetch in components
LayoutsManual with _app.jsNested layouts per route
Loading statesManual implementationloading.js convention
Error handling_error.js (global)error.js (per route)
StreamingLimitedFull 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:

FilePurpose
page.jsUI for a route (makes route accessible)
layout.jsShared UI wrapper, persists across navigations
loading.jsLoading UI (Suspense boundary)
error.jsError UI (Error boundary)
not-found.js404 UI
route.jsAPI endpoint (replaces pages/api)
template.jsLike 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 > SettingsPage

Key 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

StrategyFetch OptionUse Case
Staticcache: 'force-cache' (default)Content that rarely changes
Dynamiccache: 'no-store'User-specific data, real-time data
ISRnext: { revalidate: 60 }Content that updates periodically
On-demandrevalidateTag() / 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 made

Parallel 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 regeneration

Streaming 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       // → /register

Parallel 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 page

Navigation

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

FeatureServer ActionsAPI Routes
Use caseForm submissions, mutationsExternal APIs, webhooks
InvocationDirect function callHTTP request
Progressive enhancementBuilt-in (works without JS)Manual
Type safetyEnd-to-end with TypeScriptRequest/response typing
Caching integrationrevalidatePath/revalidateTagManual
// 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 build

Common 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

TopicKey Points
App vs Pages RouterServer Components, nested layouts, native fetch vs getServerSideProps
Server ComponentsDefault in App Router, zero client JS, direct data fetching
Client Components"use client", interactivity, hooks, browser APIs
Data FetchingNative fetch, cache options, revalidation, deduplication
RenderingStatic (default), Dynamic (no-store, cookies), ISR (revalidate), Streaming
Server Actions"use server", form handling, revalidation, progressive enhancement
Cachingfetch cache, revalidatePath, revalidateTag, request deduplication
OptimizationImage component, font optimization, Metadata API, dynamic imports

Related Articles

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides