50+ Next.js Interview Questions 2025: App Router, Server Components & More

·39 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 50+ Next.js interview questions that interviewers actually ask, from fundamental routing to advanced rendering strategies and Server Actions.

Table of Contents

  1. App Router vs Pages Router Questions
  2. Server Components vs Client Components Questions
  3. Data Fetching Questions
  4. Rendering Strategies Questions
  5. Routing and Navigation Questions
  6. Server Actions Questions
  7. Caching Questions
  8. Performance and Optimization Questions

App Router vs Pages Router Questions

Understanding the architectural differences between App Router and Pages Router is essential for any Next.js interview.

What is the difference between App Router and Pages Router?

The App Router and Pages Router represent two fundamentally different approaches to building Next.js applications. The App Router, introduced in Next.js 13 and stabilized in version 14, represents the future of Next.js development with React Server Components at its core. The Pages Router is the original approach that served the framework well for years but is now considered legacy.

App Router (app/ directory) is the modern approach:

  • Uses React Server Components by default
  • Nested layouts that persist across navigations
  • Native fetch() for data fetching with automatic caching
  • Built-in loading.js and error.js conventions
  • Full streaming support with Suspense

Pages Router (pages/ directory) is the legacy approach:

  • Client Components by default
  • getServerSideProps/getStaticProps for data fetching
  • Manual layout implementation via _app.js
  • Global error handling with _error.js
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

When should you use App Router vs Pages Router?

The choice between routers depends on your project's situation and requirements. For any greenfield project, App Router is the clear recommendation as it provides better performance, simpler patterns, and access to the latest React features. However, there are valid reasons to continue using Pages Router in certain situations.

Use App Router for:

  • New projects (recommended default)
  • When you need Server Components for performance
  • Complex nested layouts
  • Streaming and Suspense features
  • Modern React patterns (Server Actions, useFormState)

Use Pages Router for:

  • Existing projects not ready to migrate
  • When using libraries incompatible with Server Components
  • Teams more familiar with traditional React patterns

How do you migrate from Pages Router to App Router?

Migration doesn't have to be a big-bang rewrite. Next.js supports incremental adoption where both routers coexist in the same project. This allows teams to migrate route by route, testing thoroughly at each step. The key insight is that routes in the app/ directory take precedence over pages/ for the same path, making gradual migration straightforward.

  1. Create the app/ directory alongside pages/
  2. Move routes one at a time, starting with leaf routes
  3. Convert getServerSideProps/getStaticProps to fetch with cache options
  4. Replace _app.js patterns with layout.js
  5. Update client-side code to use 'use client' directive
  6. Test thoroughly—routing behavior differs slightly

What special files exist in the App Router?

The App Router uses a convention-based file system where specific filenames have special meaning. These conventions replace manual configuration and provide a consistent pattern across all Next.js applications. Each file serves a distinct purpose in the rendering lifecycle, from defining UI to handling errors and loading states.

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
default.jsFallback for parallel routes

What is the difference between layout.js and template.js?

While both layout.js and template.js wrap child routes with shared UI, they differ fundamentally in how they handle state and re-rendering. Understanding this distinction is crucial for choosing the right approach for features like navigation bars, sidebars, or page transitions.

layout.js maintains state across navigations. When a user navigates between child routes, the layout component doesn't unmount or re-render. This makes layouts perfect for persistent UI elements like navigation menus, sidebars, and headers where you want to preserve scroll position, form state, or animation state.

template.js creates a fresh instance for every navigation. Each time a user navigates to a new route within the template's scope, the template component unmounts and remounts completely. This behavior is essential for features that require a clean slate on each page, such as enter/exit animations, page view analytics that need to fire on every navigation, or any feature requiring useEffect to run on route changes.


Server Components vs Client Components Questions

React Server Components (RSC) are the foundation of App Router and a common interview topic.

What are React Server Components?

React Server Components represent a paradigm shift in how we think about React applications. Unlike traditional React components that execute in the browser, Server Components run exclusively on the server during the render phase. The rendered output—a serialized component tree—is sent to the client, but none of the component's JavaScript code ever ships to the browser.

This architecture enables direct access to server-side resources like databases, file systems, and internal APIs without exposing credentials or requiring an API layer. In Next.js App Router, all components are Server Components by default unless you explicitly opt into client-side rendering with the 'use client' directive.

// Server Component (default) - NO "use client" directive
import { db } from '@/lib/db';
 
export default async function ProductsPage() {
  // Direct database access - this code never reaches the client
  const products = await db.products.findMany();
 
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

When should you use Server Components vs Client Components?

The decision between Server and Client Components should be driven by what each component needs to accomplish. The general principle is to keep components on the server by default and only move them to the client when you need browser-specific capabilities. This approach minimizes the JavaScript sent to users while maintaining full interactivity where needed.

Use Server Components (default) for:

  • Data fetching from databases or APIs
  • Accessing backend resources (file system, internal services)
  • Keeping sensitive data on the server (API keys, tokens)
  • Large dependencies that would bloat client bundle
  • SEO-critical content

Use Client Components ('use client') for:

  • Interactivity (onClick, onChange, onSubmit)
  • React hooks (useState, useEffect, useContext, useReducer)
  • Browser APIs (localStorage, geolocation, window)
  • Third-party libraries that require client-side rendering
// Client Component - HAS "use client" directive
'use client';
 
import { useState } from 'react';
 
export function AddToCartButton({ productId }) {
  const [isAdding, setIsAdding] = useState(false);
 
  async function handleClick() {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  }
 
  return (
    <button onClick={handleClick} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

How do you pass Server Components to Client Components?

This is a critical pattern that trips up many developers new to Server Components. The key insight is that a Client Component cannot import a Server Component directly—doing so would convert the Server Component into a Client Component. Instead, you pass Server Components as children or props, which preserves their server-side execution while allowing Client Components to control their rendering.

// ✅ CORRECT: Pass Server Component as children
// 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}
    </div>
  );
}
// ❌ WRONG: Importing Server Component into Client Component
'use client';
 
import { ServerContent } from './server-content';
 
export function ClientWrapper() {
  return <ServerContent />; // ServerContent becomes a Client Component!
}

What is the serialization boundary?

The serialization boundary is the conceptual line where data crosses from Server Components to Client Components. When a Server Component passes props to a Client Component, those props must travel over the network from server to browser. This means every prop must be serializable—convertible to a format that can be transmitted as bytes and reconstructed on the other side.

Understanding this boundary helps you design component APIs that work correctly in the Server Component architecture. Functions cannot be serialized because they contain executable code tied to their original execution context. Class instances lose their prototype chain during serialization. Recognizing these constraints early prevents confusing runtime errors.

Can pass (serializable):

  • Strings, numbers, booleans
  • Arrays and plain objects
  • Dates (as ISO strings)
  • null and undefined

Cannot pass (non-serializable):

  • Functions
  • Class instances
  • Symbols
  • Maps and Sets (without conversion)
// ✅ Serializable props
<ClientComponent
  name="John"
  count={42}
  items={[1, 2, 3]}
  date={new Date().toISOString()}
/>
 
// ❌ Non-serializable - will error
<ClientComponent
  onClick={() => console.log('hi')}
  user={new User()}
/>

What happens when you add 'use client' to a file?

Adding the 'use client' directive to a file has far-reaching implications for that component and everything it imports. It marks a boundary in your component tree—everything below that boundary in the import graph becomes part of the client bundle. The component itself and all its dependencies will ship JavaScript to the browser.

Despite the name, Client Components still render on the server initially for the HTML response. The difference is that they also hydrate on the client, adding event handlers and enabling interactivity. This means Client Components get the SEO benefits of server rendering while also supporting dynamic behavior in the browser.

  1. The component can use hooks and browser APIs
  2. All components imported into it also become Client Components
  3. It still renders on the server initially (SSR) for HTML
  4. The component hydrates on the client with interactivity

How do Server Components improve performance?

Server Components deliver performance benefits through multiple mechanisms that compound together. The most obvious benefit is reduced JavaScript—Server Components send zero JavaScript to the client. But the improvements go deeper than bundle size.

By executing on the server, these components can access data sources directly without round-trips. They can use heavy dependencies without shipping those libraries to users. And they enable streaming, where the server progressively sends HTML as components render rather than waiting for everything to complete.

  1. Zero client JavaScript for Server Components
  2. Smaller bundles - dependencies stay on server
  3. Direct backend access - no API layer needed
  4. Streaming - send HTML progressively
  5. Automatic code splitting - only Client Components ship JS

Data Fetching Questions

App Router simplifies data fetching with native fetch and automatic caching.

How does data fetching work in Next.js App Router?

Data fetching in App Router abandons the specialized functions of Pages Router (getServerSideProps, getStaticProps) in favor of a more natural pattern: just use fetch. Since Server Components can be async, you simply await your data wherever you need it. This colocation of data fetching with rendering code makes components more self-contained and easier to reason about.

Next.js extends the native fetch API with caching and revalidation capabilities. Every fetch request is automatically deduplicated within a single render pass, and results can be cached across requests. This means you can fetch the same data in multiple components without worrying about redundant network calls.

async function getProducts() {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}
 
export default async function ProductsPage() {
  const products = await getProducts();
  return <ProductList products={products} />;
}

Key differences from Pages Router:

  • No getServerSideProps or getStaticProps
  • Fetch where you need data (any component)
  • Automatic request deduplication
  • Built-in caching with fine-grained control

What are the caching options for fetch in Next.js?

Next.js provides granular control over how fetch requests are cached through options passed to the fetch function. Understanding these options is essential for balancing performance (serving cached content) with freshness (getting updated data). The default behavior caches responses indefinitely, which works well for truly static content but requires explicit configuration for dynamic data.

StrategyFetch OptionBehavior
Staticcache: 'force-cache' (default)Cached indefinitely, revalidate on deploy
Dynamiccache: 'no-store'Fresh data on every request
Time-based ISRnext: { revalidate: 60 }Revalidate every 60 seconds
Tag-basednext: { tags: ['products'] }Revalidate on-demand with revalidateTag
// Static (default)
fetch('https://api.example.com/posts');
 
// Dynamic - always fresh
fetch('https://api.example.com/user', {
  cache: 'no-store'
});
 
// ISR - revalidate every hour
fetch('https://api.example.com/products', {
  next: { revalidate: 3600 }
});
 
// Tagged for on-demand revalidation
fetch(`https://api.example.com/product/${id}`, {
  next: { tags: ['products', `product-${id}`] }
});

What is request deduplication in Next.js?

Request deduplication is an automatic optimization that prevents redundant network requests during a single render pass. When multiple components in a render tree request the same URL with the same options, Next.js makes only one actual network request and shares the result. This optimization happens transparently without any configuration required.

This feature is particularly valuable because it encourages good component design. You can fetch data where you need it without worrying about creating a data-fetching hierarchy or prop drilling. Each component can be self-sufficient, requesting its own data, while the framework ensures efficiency under the hood.

async function getUser(id) {
  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); // Request #1
  return <div><UserNav user={user} />{children}</div>;
}
 
// Page also fetches same user
async function Page() {
  const user = await getUser(1); // Deduplicated - no new request!
  return <UserProfile user={user} />;
}

What is the difference between parallel and sequential data fetching?

The order in which data fetching operations execute can dramatically impact your page's loading performance. Sequential fetching, where each request waits for the previous one to complete, creates a waterfall effect that compounds latency. Parallel fetching fires all requests simultaneously, reducing total wait time to the duration of the slowest request.

Choosing between these patterns depends on data dependencies. If one fetch requires data from another (like fetching user details then their posts), sequential is necessary. For independent data (user, products, notifications), parallel fetching with Promise.all is almost always the better choice.

Sequential (slow) - each fetch waits for the previous:

async function Page() {
  const user = await getUser();      // Wait 200ms
  const posts = await getPosts();    // Then wait 300ms
  const comments = await getComments(); // Then wait 200ms
  // Total: 700ms
}

Parallel (fast) - all fetches start simultaneously:

async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),      // 200ms
    getPosts(),     // 300ms  } All run together
    getComments(),  // 200ms
  ]);
  // Total: 300ms (longest request)
}

Streaming with Suspense provides progressive loading where content appears as it becomes ready:

function Page() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <User />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <Posts />
      </Suspense>
    </div>
  );
}

How do you fetch data in Client Components?

Client Components cannot be async functions, which means you cannot use the simple await pattern available in Server Components. Instead, you need to manage loading states explicitly using one of several patterns. The best approach depends on your requirements for caching, revalidation, and error handling.

The recommended approach is to fetch data in a parent Server Component and pass it down as props. This gives you the simplicity of server-side fetching while allowing the Client Component to add interactivity. For data that must be fetched on the client (like user-specific data after authentication), use a data fetching library like SWR or React Query.

1. SWR or React Query (recommended for client fetching):

'use client';
 
import useSWR from 'swr';
 
export function UserProfile({ userId }) {
  const { data, error, isLoading } = useSWR(
    `/api/users/${userId}`,
    fetcher
  );
 
  if (isLoading) return <Skeleton />;
  if (error) return <Error />;
  return <Profile user={data} />;
}

2. useEffect (not recommended for initial data):

'use client';
 
import { useState, useEffect } from 'react';
 
export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
 
  return user ? <Profile user={user} /> : <Skeleton />;
}

3. Pass data from Server Component (preferred):

// Server Component
export default async function Page() {
  const user = await getUser();
  return <ClientProfile user={user} />;
}
 
// Client Component
'use client';
export function ClientProfile({ user }) {
  // Has data immediately, no loading state
  return <Profile user={user} />;
}

What triggers dynamic rendering in Next.js?

Next.js automatically determines whether a route should be static or dynamic based on the features it uses. Certain APIs and configurations signal that a page needs fresh data on every request, triggering dynamic rendering. Understanding these triggers helps you make intentional decisions about your page's rendering strategy.

A route becomes dynamic when using any of these features:

  1. cache: 'no-store' in fetch
  2. cookies() or headers() functions
  3. searchParams prop in page component
  4. export const dynamic = 'force-dynamic'
import { cookies, headers } from 'next/headers';
 
export default async function Page({ searchParams }) {
  // Any of these makes the page dynamic:
 
  const cookieStore = cookies();  // Dynamic
  const headersList = headers();   // Dynamic
  const query = searchParams.q;    // Dynamic
 
  const data = await fetch('...', {
    cache: 'no-store'  // Dynamic
  });
}

Rendering Strategies Questions

Next.js offers multiple rendering approaches for different use cases.

What is Static Rendering in Next.js?

Static Rendering generates HTML at build time, creating files that can be served directly from a CDN edge location closest to your users. This approach provides the fastest possible response times because there's no server computation on each request—the pre-built HTML is simply returned immediately.

Static rendering is the default behavior in Next.js App Router when your page doesn't use any dynamic features. It's ideal for content that doesn't change between requests, like marketing pages, blog posts, documentation, and product pages where the content is the same for all users.

// This page is static by default
export default async function AboutPage() {
  const content = await fetch('https://cms.example.com/about', {
    cache: 'force-cache' // Default - can be omitted
  });
 
  return <div>{content.body}</div>;
}

Benefits:

  • Fastest possible response (served from CDN edge)
  • No server computation per request
  • Excellent for SEO

Best for: Marketing pages, blog posts, documentation, product pages

What is Dynamic Rendering in Next.js?

Dynamic Rendering generates HTML fresh on every request, allowing your page to include user-specific data, real-time information, or any content that varies between requests. The server executes your component code each time someone visits the page, ensuring they always see current data.

While dynamic rendering has higher latency than static (the server must process each request), it's essential for personalized experiences. User dashboards, shopping carts, authenticated content, and real-time data displays all require dynamic rendering to show the right content to each user.

import { cookies } from 'next/headers';
 
export default async function DashboardPage() {
  const session = cookies().get('session');
 
  const userData = await fetch('https://api.example.com/me', {
    cache: 'no-store',
    headers: { Authorization: `Bearer ${session}` }
  });
 
  return <Dashboard data={userData} />;
}

Triggers: cookies(), headers(), searchParams, cache: 'no-store'

Best for: User dashboards, personalized content, real-time data

What is ISR (Incremental Static Regeneration)?

ISR bridges the gap between static and dynamic rendering by allowing static pages to update after deployment without rebuilding the entire site. Pages are generated statically but can be revalidated—regenerated in the background—based on time intervals or on-demand triggers.

When a request arrives after the revalidation period, Next.js serves the cached (stale) content immediately while regenerating the page in the background. The next visitor receives the fresh content. This "stale-while-revalidate" approach provides static-level performance with near-real-time content freshness.

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>;
}
 
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts')
    .then(r => r.json());
 
  return posts.map(post => ({ slug: post.slug }));
}

How it works:

  1. Build time: Generate static pages for all known slugs
  2. First request to new slug: Generate on-demand, cache it
  3. After revalidate period: Serve stale, regenerate in background
  4. Next request: Serve fresh page

Best for: Blog posts, product pages, content that updates periodically

What is Streaming and how does it work with Suspense?

Streaming fundamentally changes how HTML is delivered to the browser. Instead of waiting for the entire page to render before sending anything, the server sends HTML progressively as each part of the page becomes ready. This dramatically improves perceived performance by showing users content faster, even when some parts of the page are slow.

Suspense boundaries define the streaming chunks. Each component wrapped in Suspense can load independently, showing its fallback UI until the async content is ready. The server streams the shell immediately, then streams in each Suspense boundary's content as it resolves. This creates a progressive loading experience where fast content appears instantly while slower content loads in the background.

import { Suspense } from 'react';
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
 
      {/* Shows immediately */}
      <WelcomeMessage />
 
      {/* Streams in when ready */}
      <Suspense fallback={<ChartSkeleton />}>
        <SlowChart />
      </Suspense>
 
      <Suspense fallback={<TableSkeleton />}>
        <SlowDataTable />
      </Suspense>
    </div>
  );
}
 
async function SlowChart() {
  const data = await fetchAnalytics(); // Takes 2 seconds
  return <Chart data={data} />;
}

Timeline:

0ms:    [Welcome] [Loading...] [Loading...]
500ms:  [Welcome] [  Chart  ] [Loading...]
1200ms: [Welcome] [  Chart  ] [  Table  ]

Benefits:

  • Faster Time to First Byte (TTFB)
  • Progressive content display
  • Unblocks fast parts from slow parts

What is the difference between generateStaticParams and dynamic routes?

Dynamic routes in Next.js (using bracket notation like [slug]) can render any matching URL. By default, these routes are generated on-demand when first requested. The generateStaticParams function changes this behavior by specifying which parameter values should be pre-rendered at build time.

Without generateStaticParams, your dynamic routes still work—they just generate pages on the first request and cache them. With generateStaticParams, those pages exist immediately after build, providing instant responses from the CDN without any initial generation delay.

// app/blog/[slug]/page.js
 
// Tell Next.js which slugs to pre-render
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json());
 
  return posts.map(post => ({
    slug: post.slug,
  }));
}
 
// This runs for each slug at build time
export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return <article>{post.content}</article>;
}

Without generateStaticParams: Routes are generated on-demand (first request)

With generateStaticParams: Routes are pre-built, instant response from CDN

How do you force a page to be static or dynamic?

Sometimes you need to override Next.js's automatic rendering mode detection. Route segment config options let you explicitly control whether a page should be static, dynamic, or use ISR. These exports at the top of your page file take precedence over automatic detection.

Use force-static when you know a page should be cached even if it technically uses some dynamic-looking code. Use force-dynamic when you need fresh data on every request regardless of caching hints. The revalidate export provides ISR behavior for the entire route without needing to configure individual fetch calls.

// Force static (error if dynamic features used)
export const dynamic = 'force-static';
 
// Force dynamic (always render on request)
export const dynamic = 'force-dynamic';
 
// Set revalidation for entire page
export const revalidate = 60; // ISR: 60 seconds
 
// Error if page takes too long
export const maxDuration = 30; // 30 seconds max

Routing and Navigation Questions

App Router provides powerful file-based routing with advanced patterns.

How does file-based routing work in App Router?

File-based routing maps your project's folder structure directly to URL paths. Each folder represents a route segment, and special files within those folders define the UI for that route. This convention eliminates the need for a separate routing configuration while providing clear, predictable URL structures.

The page.js file makes a route publicly accessible—without it, a folder merely groups related files. This means you can colocate components, utilities, and tests alongside your page files without accidentally creating routes. The folder-equals-URL principle makes it easy to understand your application's structure at a glance.

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)
    └── pricing/
        └── page.js            → /pricing

What are dynamic route segments?

Dynamic route segments capture variable parts of URLs, allowing a single page component to handle multiple URLs that follow a pattern. Square brackets indicate dynamic segments, with different bracket patterns providing different behaviors for matching and capturing URL parts.

Understanding the distinction between single segments [id], catch-all segments [...slug], and optional catch-all segments [[...slug]] is essential for designing flexible routing structures. Each pattern serves different use cases, from simple ID-based pages to complex hierarchical navigation.

// [slug] - Single dynamic segment
// app/blog/[slug]/page.js
export default function BlogPost({ params }) {
  return <div>Post: {params.slug}</div>;
}
// /blog/hello-world → params.slug = 'hello-world'
 
// [...categories] - Catch-all segment
// app/shop/[...categories]/page.js
export default function Category({ params }) {
  return <div>{params.categories.join(' > ')}</div>;
}
// /shop/electronics/phones → params.categories = ['electronics', 'phones']
 
// [[...slug]] - Optional catch-all
// app/docs/[[...slug]]/page.js
export default function Docs({ params }) {
  return <div>{params.slug?.join('/') || 'index'}</div>;
}
// /docs → params.slug = undefined
// /docs/api/auth → params.slug = ['api', 'auth']

What are Route Groups and when should you use them?

Route Groups provide a way to organize routes without affecting the URL structure. Created by wrapping a folder name in parentheses (folder), route groups let you apply different layouts to different sections of your site, organize code logically, or opt routes into or out of shared layouts—all while maintaining clean, predictable URLs.

This feature solves a common tension in file-based routing: the desire for logical code organization often conflicts with desired URL structure. Route groups decouple these concerns, letting your folder structure serve your development needs while your URLs serve your users.

app/
├── (marketing)/          # Group: marketing layout
│   ├── layout.js         # Shared marketing layout
│   ├── about/
│   │   └── page.js       # → /about
│   └── pricing/
│       └── page.js       # → /pricing
├── (shop)/               # Group: shop layout
│   ├── layout.js         # Shared shop layout
│   └── products/
│       └── page.js       # → /products
└── (auth)/               # Group: minimal auth layout
    ├── layout.js
    └── login/
        └── page.js       # → /login

Use cases:

  • Different layouts for different sections
  • Organizing code without affecting URLs
  • Opting specific routes into/out of layouts

What are Parallel Routes?

Parallel Routes enable rendering multiple page components simultaneously in the same layout, each in its own "slot." Defined by folders prefixed with @, these slots become props in the parent layout, allowing you to compose complex UIs from independent, simultaneously-rendered page components.

This pattern is particularly powerful for dashboard interfaces where multiple panels need to load independently, or for implementing modal patterns where the modal content is a separate route that can be accessed directly via URL.

app/
├── layout.js
├── page.js
├── @analytics/          # Parallel route slot
│   └── page.js
└── @team/               # Another slot
    └── page.js
// app/layout.js
export default function Layout({ children, analytics, team }) {
  return (
    <div>
      <main>{children}</main>
      <aside>
        {analytics}
        {team}
      </aside>
    </div>
  );
}

Use cases: Dashboards, split views, modals

What are Intercepting Routes?

Intercepting Routes let you display different content depending on how a user navigates to a URL. When a user clicks a link within your app (soft navigation), you can intercept that navigation and show alternative UI—like a modal—while still updating the URL. Direct navigation to that URL (hard navigation via typing in address bar or refreshing) shows the full page instead.

This pattern is common in social media applications where clicking a photo in a feed opens it in a modal overlay while keeping the feed visible, but navigating directly to the photo URL shows a full-screen view. The URL reflects the content being viewed, making it shareable and bookmarkable.

app/
├── feed/
│   └── page.js              # Main feed
├── photo/
│   └── [id]/
│       └── page.js          # Full photo page (direct URL)
└── @modal/
    └── (.)photo/            # (.) intercepts same level
        └── [id]/
            └── page.js      # Photo modal (soft navigation)

Behavior:

  • Click photo in feed → Shows modal at /photo/123
  • Direct URL /photo/123 → Shows full page
  • Refresh on /photo/123 → Shows full page

Convention: (.) same level, (..) one level up, (...) root

How do you navigate programmatically in Next.js?

Programmatic navigation handles cases where navigation needs to happen in response to events or logic rather than direct user clicks. The useRouter hook from next/navigation provides methods for navigation, history manipulation, and cache control that you can call from event handlers, after form submissions, or in response to any programmatic trigger.

Note that useRouter requires a Client Component since it uses React hooks. For simple link-based navigation, prefer the Link component which works in both Server and Client Components and provides better accessibility and prefetching by default.

'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'); // No history entry
      // router.refresh();             // Refresh Server Components
      // router.back();                // Go back
      // router.prefetch('/settings'); // Prefetch route
    }
  }
 
  return <button onClick={handleLogin}>Login</button>;
}

Both Link and useRouter enable navigation, but they serve different purposes and offer different capabilities. Choosing the right tool depends on whether navigation is declarative (user clicks something) or imperative (code decides to navigate).

Link is a component that renders an anchor tag, providing all the benefits of proper HTML links: right-click context menus, keyboard accessibility, and progressive enhancement. It also automatically prefetches linked routes in the viewport, improving perceived performance. Use Link for any clickable navigation element.

useRouter provides programmatic navigation methods that you call from code. This is necessary when navigation depends on logic—after successful form submission, authentication completion, or any conditional navigation. It's also the only way to trigger refresh, back, or prefetch actions programmatically.

Link - Declarative, preferred for most navigation:

import Link from 'next/link';
 
<Link href="/about">About</Link>
<Link href="/blog/post-1" prefetch={false}>Post</Link>
<Link href="/login" replace>Login</Link>

useRouter - Programmatic, for event-driven navigation:

const router = useRouter();
router.push('/dashboard'); // After form submit, auth, etc.

Server Actions Questions

Server Actions handle mutations without creating API routes.

What are Server Actions?

Server Actions are async functions marked with 'use server' that execute on the server but can be called directly from client-side code. They provide a streamlined way to handle form submissions and data mutations without building separate API endpoints. The framework handles the request/response cycle, serialization, and error handling automatically.

This abstraction dramatically simplifies the code needed for common operations like form handling. Instead of creating an API route, wiring up a fetch call, and managing loading states manually, you write a function that does the work and call it directly. Server Actions integrate with React's form handling, cache invalidation, and navigation seamlessly.

// 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');
 
  const post = await db.posts.create({
    data: { title, content }
  });
 
  revalidatePath('/posts');
  redirect(`/posts/${post.id}`);
}
// app/posts/new/page.js
import { createPost } from '../actions';
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  );
}

How do you handle form validation with Server Actions?

Form validation with Server Actions uses useFormState to maintain validation state across submissions. The action function receives the previous state as its first argument, allowing you to return error messages that persist in the UI. This pattern enables server-side validation while providing immediate feedback to users.

The useFormStatus hook complements this by providing pending state during submission, enabling loading indicators on submit buttons. Together, these hooks create a complete form handling solution that works even without JavaScript for progressive enhancement.

// app/actions.js
'use server';
 
export async function createUser(prevState, formData) {
  const email = formData.get('email');
 
  if (!email.includes('@')) {
    return { error: 'Invalid email address' };
  }
 
  const existing = await db.users.findByEmail(email);
  if (existing) {
    return { error: 'Email already registered' };
  }
 
  await db.users.create({ email });
  return { success: true };
}
// app/register/page.js
'use client';
 
import { useFormState, useFormStatus } from 'react-dom';
import { createUser } from '../actions';
 
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? 'Creating...' : 'Create Account'}
    </button>
  );
}
 
export default function RegisterPage() {
  const [state, formAction] = useFormState(createUser, {});
 
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state.error && <p className="error">{state.error}</p>}
      <SubmitButton />
    </form>
  );
}

How do you implement optimistic updates?

Optimistic updates provide instant feedback by updating the UI immediately while the actual operation happens in the background. The useOptimistic hook from React enables this pattern by maintaining an optimistic state that can be updated synchronously while waiting for the async action to complete.

This pattern is essential for creating responsive, app-like experiences where users see immediate results of their actions. Common use cases include like buttons, adding items to lists, or any interaction where waiting for server confirmation would feel sluggish.

'use client';
 
import { useOptimistic } from 'react';
import { likePost } from './actions';
 
export function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state) => state + 1
  );
 
  async function handleLike() {
    addOptimisticLike(); // Instant UI update
    await likePost(postId); // Server action (may take time)
  }
 
  return (
    <form action={handleLike}>
      <button type="submit">❤️ {optimisticLikes}</button>
    </form>
  );
}

What is the difference between Server Actions and API Routes?

Server Actions and API Routes both run code on the server, but they serve different purposes and have different characteristics. Understanding when to use each helps you choose the right tool for your specific needs.

Server Actions are designed for internal application operations, particularly form handling and data mutations. They integrate tightly with React, support progressive enhancement (forms work without JavaScript), and provide built-in type safety between client and server code. They're invoked by direct function calls from your components.

API Routes create HTTP endpoints that can be called by anything—external services, webhooks, mobile apps, or any HTTP client. They're necessary when you need a traditional REST or GraphQL API, when handling webhooks from third-party services, or when building a public API for others to consume.

FeatureServer ActionsAPI Routes
Use caseForms, mutationsWebhooks, external APIs
InvocationDirect function callHTTP request
Progressive enhancementBuilt-inManual
Type safetyEnd-to-end TypeScriptRequest/response typing
Cache integrationrevalidatePath/TagManual

Use Server Actions for: Form submissions, data mutations, internal app operations

Use API Routes for: Webhooks, third-party integrations, public APIs

How do you revalidate data after a Server Action?

After mutating data with a Server Action, you typically need to invalidate cached data so users see the updated state. Next.js provides two functions for this: revalidatePath for invalidating entire routes, and revalidateTag for invalidating specific tagged fetch requests.

Choosing between these depends on how granular you need your invalidation to be. Path-based revalidation is simpler but less precise—it invalidates all cached data for a route. Tag-based revalidation requires more setup (tagging your fetches) but allows surgical invalidation of just the data that changed.

'use server';
 
import { revalidatePath, revalidateTag } from 'next/cache';
 
export async function updateProduct(id, data) {
  await db.products.update(id, data);
 
  // Revalidate specific path
  revalidatePath('/products');
  revalidatePath(`/products/${id}`);
 
  // Or revalidate by tag (if fetch used tags)
  revalidateTag('products');
  revalidateTag(`product-${id}`);
}

Caching Questions

Understanding Next.js caching is crucial for performance optimization.

What are the four caching layers in Next.js?

Next.js implements a sophisticated multi-layer caching system that optimizes performance at every level of the request lifecycle. Each layer serves a different purpose and operates at a different scope, from individual requests to cross-request persistence to client-side navigation.

Understanding these layers helps you make informed decisions about cache configuration and troubleshoot unexpected behavior. The layers build on each other—a cache hit at an earlier layer means subsequent layers never need to be consulted for that data.

  1. Request Memoization - Deduplicates fetch calls within a single render, preventing redundant network requests when multiple components need the same data
  2. Data Cache - Persists fetch results on the server across requests and deployments, enabling static-like performance for dynamic content
  3. Full Route Cache - Caches the complete rendered output (HTML and RSC payload) for static routes
  4. Router Cache - Client-side cache that stores prefetched routes and visited pages for instant navigation

How do you invalidate the cache?

Cache invalidation in Next.js can be time-based (automatic) or on-demand (triggered by your code). Time-based revalidation is simpler to set up but less precise—content updates on a schedule regardless of whether anything changed. On-demand revalidation gives you control over exactly when caches are cleared, typically in response to data mutations.

For most applications, a combination of both strategies works best: time-based revalidation as a safety net to ensure content eventually updates, and on-demand revalidation for immediate updates after user actions.

Time-based revalidation:

// Revalidate every 60 seconds
fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
});
 
// Or at page level
export const revalidate = 60;

On-demand revalidation:

'use server';
 
import { revalidatePath, revalidateTag } from 'next/cache';
 
export async function updateData() {
  await saveData();
 
  revalidatePath('/dashboard');     // Revalidate path
  revalidateTag('dashboard-data'); // Revalidate tagged fetches
}

What is the difference between revalidatePath and revalidateTag?

Both functions invalidate cached content, but they target different things. revalidatePath operates on routes—it clears all cached content for specified paths, including the Full Route Cache and all Data Cache entries associated with that route. revalidateTag is more surgical, invalidating only fetch responses that were marked with specific tags.

In practice, use revalidatePath when a change affects an entire page or when you want simple, comprehensive cache clearing. Use revalidateTag when you've structured your data fetching with tags and want fine-grained control over what gets invalidated.

revalidatePath - Invalidates all cached data for a route:

revalidatePath('/blog');           // Single path
revalidatePath('/blog/[slug]', 'page'); // Dynamic route
revalidatePath('/', 'layout');     // Everything using root layout

revalidateTag - Invalidates specific tagged fetches:

// Tag your fetches
fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
});
 
// Revalidate only those fetches
revalidateTag('posts');

How do you opt out of caching?

Sometimes you need fresh data on every request, regardless of default caching behavior. Next.js provides several ways to opt out of caching at different levels of granularity—from a single fetch request to an entire route segment.

Use these options deliberately, as opting out of caching means slower responses and more server load. The most common legitimate use cases are user-specific data that can't be shared, rapidly changing data where staleness is unacceptable, or debugging cache-related issues.

// Per-fetch: no caching
fetch('https://api.example.com/data', {
  cache: 'no-store'
});
 
// Per-route: force dynamic
export const dynamic = 'force-dynamic';
 
// Per-route: disable all fetch caching
export const fetchCache = 'force-no-store';

Performance and Optimization Questions

How does the Next.js Image component improve performance?

The Next.js Image component provides automatic image optimization that would require significant manual effort to implement correctly. It addresses common performance problems like serving oversized images, layout shift during loading, and inefficient image formats—all while providing a simple API similar to the native img element.

Under the hood, the component generates multiple sizes of each image, serves modern formats (WebP/AVIF) when supported, and lazy loads images below the fold. This combination of optimizations can dramatically improve Core Web Vitals, particularly Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS).

import Image from 'next/image';
 
export function ProductImage({ product }) {
  return (
    <Image
      src={product.imageUrl}
      alt={product.name}
      width={800}
      height={600}
      priority={true}              // Above-the-fold
      placeholder="blur"           // Blur while loading
      blurDataURL={product.blur}   // Base64 placeholder
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  );
}

Optimizations:

  • Automatic WebP/AVIF format conversion
  • Responsive sizing based on device
  • Lazy loading by default
  • Prevents Cumulative Layout Shift (CLS)
  • On-demand optimization (not at build time)

How do you optimize fonts in Next.js?

The next/font module solves the performance problems traditionally associated with web fonts: layout shift during loading, render-blocking requests to external font servers, and privacy concerns from third-party requests. It does this by self-hosting fonts and automatically inlining optimal font CSS.

When you use next/font/google, Next.js downloads the font files at build time, optimizes them with subsetting (removing unused characters), and serves them from your own domain. This eliminates external network requests while ensuring fonts load as quickly as possible with zero layout shift.

import { Inter, Roboto_Mono } from 'next/font/google';
 
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});
 
export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Benefits:

  • Self-hosted fonts (no external requests)
  • Automatic font subsetting
  • Zero layout shift
  • Preloaded with proper hints

How do you implement dynamic imports in Next.js?

Dynamic imports enable code splitting at the component level, allowing you to defer loading of heavy components until they're actually needed. This reduces the initial bundle size, improving load times especially for features that aren't immediately visible or are only used by some users.

The next/dynamic function wraps React's lazy with additional features useful in Next.js applications. You can specify loading components, disable server-side rendering for components that require browser APIs, and handle named exports from modules.

import dynamic from 'next/dynamic';
 
// Lazy load heavy components
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Client-only (for browser-dependent libraries)
});
 
// Named exports
const Modal = dynamic(() =>
  import('./Modal').then(mod => mod.Modal)
);

How do you configure the Metadata API for SEO?

The Metadata API provides a type-safe way to define page metadata for SEO, social sharing, and browser behavior. You can export a static metadata object for pages with known metadata, or a generateMetadata function for dynamic pages that need to compute metadata based on params or data fetching.

This API generates proper <head> elements including title, description, Open Graph tags, Twitter cards, and more. It handles merging metadata from layouts and pages, with more specific metadata overriding general metadata from parent layouts.

// 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: 'Description',
    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: {
      images: [post.coverImage],
    },
  };
}

What are common Next.js performance best practices?

Performance optimization in Next.js involves making intentional choices about rendering strategies, data fetching patterns, and asset optimization. The framework provides powerful defaults, but understanding these best practices helps you make the most of its capabilities and avoid common pitfalls.

The most impactful optimization is using Server Components by default—this single practice reduces client JavaScript, enables direct data access, and improves initial load times. Beyond this foundational choice, focus on parallelizing independent data fetching, using streaming for slow content, and optimizing assets with the built-in Image and Font components.

  1. Use Server Components by default - Less client JavaScript
  2. Parallel data fetching - Promise.all for independent requests
  3. Streaming with Suspense - Progressive loading
  4. Image optimization - Always use next/image
  5. Font optimization - Use next/font
  6. Route segment config - Set appropriate caching
  7. Dynamic imports - Code split heavy components
  8. Bundle analysis - Monitor bundle size
# Analyze bundle
ANALYZE=true npm run build

Quick Reference

TopicKey Points
App vs Pages RouterServer Components, nested layouts, native fetch
Server ComponentsDefault, zero JS, direct backend access
Client Components'use client', hooks, interactivity
Data FetchingNative fetch, cache options, deduplication
RenderingStatic (default), Dynamic, ISR, Streaming
Server Actions'use server', forms, revalidation
Caching4 layers, revalidatePath, revalidateTag
OptimizationImage, Font, Metadata, dynamic imports

Ready to ace your interview?

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

View PDF Guides