47 Next.js Interview Questions and Answers

·43 min read
By ·Updated
nextjsreactfrontendserver-componentsjavascriptinterview-preparation

Next.js is a widely used production React framework. In a modern interview, naming the version matters: caching defaults and request APIs changed in Next.js 15, while Next.js 16 added Cache Components, made Turbopack the default, and renamed Middleware to Proxy.

This guide covers 47 Next.js interview questions, from routing fundamentals to Next.js 16 rendering, caching, Server Actions, and performance.

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 different application models. The App Router, introduced in Next.js 13 and declared stable in 13.4, is the recommended choice for new work and has React Server Components at its core. The Pages Router remains supported; calling it removed or unsupported would be inaccurate.

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

  • Uses React Server Components by default
  • Nested layouts that persist across navigations
  • Direct server-side data access and explicit caching controls
  • Built-in loading.js and error.js conventions
  • Full streaming support with Suspense

Pages Router (pages/ directory) is the original, still-supported 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, useActionState)

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 changed in Next.js 16?

The interview-relevant changes are architectural, not just release trivia:

  • Turbopack is the default bundler for both next dev and next build; use --webpack only when you deliberately opt out.
  • Cache Components are enabled with cacheComponents: true and combine Partial Prerendering with the explicit 'use cache' directive.
  • middleware.ts is deprecated in favor of proxy.ts; Proxy runs on the Node.js runtime.
  • Request APIs such as params, searchParams, cookies(), and headers() are asynchronous.
  • revalidateTag(tag, profile) supports stale-while-revalidate, while updateTag(tag) gives a Server Action read-your-own-writes behavior.

What are Cache Components and Partial Prerendering?

Cache Components are the opt-in Next.js 16 caching model:

// next.config.ts
const nextConfig = { cacheComponents: true }
export default nextConfig

Add 'use cache' to a page, component, or function that may be reused. cacheLife() defines its lifetime and cacheTag() connects it to on-demand invalidation. Uncached runtime work goes behind a Suspense boundary. Next.js can then prerender a static shell and stream dynamic holes at request time—this is Partial Prerendering (PPR).

What replaced Middleware in Next.js 16?

proxy.ts replaces the middleware.ts convention. Rename the exported function to proxy; a codemod is available. Proxy is useful for request-dependent redirects, rewrites, or header changes at the network boundary, but it should not become a slow data-fetching or full authorization layer. Prefer redirects in next.config.ts for simple static redirects.

// proxy.ts
import { NextResponse } from 'next/server'
 
export function proxy(request: Request) {
  return NextResponse.next()
}

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 lets Server Components fetch over HTTP or call server-only data sources directly. Treat caching as an explicit design decision.

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 persistent cache and revalidation options. Since Next.js 15, a plain fetch is not persisted in the Data Cache by default. React can still memoize identical GET/HEAD requests during a render pass; for database or SDK calls, use React cache() when you need request-scoped deduplication.

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)
  • Request-scoped memoization for eligible GET/HEAD fetches
  • Opt-in persistent caching with fine-grained control

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

Next.js provides granular control over how server-side fetches interact with its persistent Data Cache. A plain fetch uses the auto no cache behavior; opt into persistence deliberately. This table describes the previous caching model, which remains available when Cache Components are disabled.

StrategyFetch OptionBehavior
Cachedcache: 'force-cache'Reuse a persistent Data Cache entry
Dynamiccache: 'no-store'Fresh data on every request
Time-based ISRnext: { revalidate: 60 }Revalidate every 60 seconds
Tag-basednext: { tags: ['products'] }Associate the entry with an invalidation tag
// Opt into persistent caching
fetch('https://api.example.com/posts', { cache: 'force-cache' });
 
// 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 memoization applies to eligible GET and HEAD fetches during React rendering; it is not the same as the persistent Data Cache. Route Handlers are outside the React component tree, and non-fetch data access should use React cache() when request-scoped memoization is needed.

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 = await cookies();
  const headersList = await headers();
  const query = (await searchParams).q;
 
  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.

Next.js can prerender a route at build time when it does not require request-time data. With Cache Components enabled, cached work contributes to a prerendered shell while runtime work is streamed through Suspense. Static rendering is ideal for content that does not vary per request, such as marketing pages, blog posts, and documentation.

// Explicitly cache the remote response
export default async function AboutPage() {
  const content = await fetch('https://cms.example.com/about', {
    cache: 'force-cache'
  });
 
  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 = (await cookies()).get('session');
 
  const userData = await fetch('https://api.example.com/me', {
    cache: 'no-store',
    headers: { Authorization: `Bearer ${session}` }
  });
 
  return <Dashboard data={userData} />;
}

Typical request-time signals: cookies(), headers(), page searchParams, connection(), and uncached data access.

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 { slug } = await params;
  const post = await getPost(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 a new slug: Render it on demand; persistence depends on the cache configuration
  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. The generateStaticParams function supplies parameter values that Next.js should prerender at build time. Other parameter values can still be rendered at request time unless dynamicParams = false is configured.

Without generateStaticParams, matching routes still work, but rendering and persistence follow the route's caching model. With it, known values can be included in the prerender. In Next.js 16, params is a Promise and must be awaited (or read with React use).

// 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 { slug } = await params;
  const post = await getPost(slug);
  return <article>{post.content}</article>;
}

Without generateStaticParams: Parameters are resolved at request time

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

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

Without Cache Components, route segment config options can explicitly control whether a page is static, dynamic, or revalidated. With cacheComponents: true, use the explicit Cache Components APIs instead; options including dynamic, revalidate, and fetchCache are disabled or unnecessary in that model.

In the previous model, use force-dynamic when the entire route must render per request and revalidate for route-level ISR. Be careful with force-static: request-time APIs such as cookies(), headers(), and useSearchParams() return empty values rather than turning personalized content into safely shared cached output.

// Previous caching model only
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
 
// Deployment-platform execution hint, not a cache control
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 async function BlogPost({ params }) {
  const { slug } = await params;
  return <div>Post: {slug}</div>;
}
// /blog/hello-world → params.slug = 'hello-world'
 
// [...categories] - Catch-all segment
// app/shop/[...categories]/page.js
export default async function Category({ params }) {
  const { categories } = await params;
  return <div>{categories.join(' > ')}</div>;
}
// /shop/electronics/phones → params.categories = ['electronics', 'phones']
 
// [[...slug]] - Optional catch-all
// app/docs/[[...slug]]/page.js
export default async function Docs({ params }) {
  const { slug } = await params;
  return <div>{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 React useActionState to maintain validation state across submissions. The action function receives the previous state as its first argument, allowing you to return structured validation errors. Validate and authorize again on the server even when HTML validation exists.

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 { useActionState } from 'react';
import { 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] = useActionState(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 a mutation, invalidate only the cached data that became stale. revalidatePath targets a route. revalidateTag(tag, 'max') marks tagged data stale and refreshes it in the background. updateTag(tag) is limited to Server Actions and expires tagged data immediately, which is useful when the same user must read their own write on the next render.

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, updateTag } from 'next/cache';
 
export async function updateProduct(id, data) {
  await db.products.update(id, data);
 
  // Revalidate specific path
  revalidatePath('/products');
  revalidatePath(`/products/${id}`);
 
  // SWR semantics: stale content may be served while it refreshes
  revalidateTag('products', 'max');
 
  // Immediate expiry in a Server Action (read your own writes)
  updateTag(`product-${id}`);
}

Caching Questions

Understanding Next.js caching is crucial for performance optimization.

What are the four caching layers in Next.js?

The familiar four-layer explanation describes the previous App Router caching model. It is still useful in projects where cacheComponents is off, but say that explicitly in an interview. Cache Components changes the mental model toward opt-in cached scopes and a prerendered shell.

  1. Request Memoization - React deduplicates eligible fetch calls during a render pass
  2. Data Cache - Persists explicitly cached fetch results across requests and deployments
  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

With Cache Components enabled, 'use cache' can cache a page, component, or function. cacheLife defines freshness, cacheTag groups entries, PPR creates the static shell, and request-time holes stream behind Suspense.

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.

Choose semantics from the product requirement: time-based freshness, stale-while-revalidate, or immediate expiry after a mutation. Avoid broad invalidation as a reflex.

Time-based revalidation:

// Cache Components model
import { cacheLife, cacheTag } from 'next/cache';
 
async function getDashboard() {
  'use cache';
  cacheLife('minutes');
  cacheTag('dashboard-data');
  return db.dashboard.findMany();
}

On-demand revalidation:

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

What is the difference between revalidatePath and revalidateTag?

Both functions invalidate cached content, but they target different scopes. revalidatePath targets a page or layout path. revalidateTag targets every cached entry associated with a tag, so it can update data reused across several routes.

Prefer tag-based invalidation for shared data because it is more precise. In Next.js 16, call revalidateTag(tag, 'max') for recommended stale-while-revalidate behavior. Use updateTag(tag) inside a Server Action when the caller must see the mutation immediately.

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'] }
});
 
// Mark them stale and refresh in the background
revalidateTag('posts', 'max');

How do you opt out of caching?

Since Next.js 15, fetch requests are not persisted by default. Use cache: 'no-store' when you want to document and enforce a fresh server fetch. In the previous model, route segment configuration can force a whole route dynamic. With Cache Components, simply keep request-dependent work outside a 'use cache' scope and place it behind Suspense when required.

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'
});
 
// Previous caching model: force the whole route dynamic
export const dynamic = 'force-dynamic';
 
// Previous caching model: disable fetch caching for the route
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 { slug } = await params;
  const post = await getPost(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 - Use next/image when its responsive loading and optimization pipeline fit the asset
  5. Font optimization - Use next/font
  6. Explicit caching - Choose Cache Components or configure the previous model intentionally
  7. Dynamic imports - Code split heavy components
  8. Bundle analysis - Monitor bundle size

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 FetchingServer-side fetch/DB access; fetch is uncached by default
RenderingPrerendering, request-time rendering, PPR, streaming
Server Actions'use server', forms, validation, authorization
Caching'use cache', cacheLife/cacheTag, updateTag/revalidateTag
Next.js 16Turbopack default, Proxy, async request APIs
OptimizationImage, Font, Metadata, dynamic imports, bundle analysis

Official Sources


Frequently Asked Questions

What is the difference between App Router and Pages Router in Next.js?

App Router (app/ directory) uses React Server Components by default, nested layouts, streaming, Server Functions, and the modern caching APIs. Pages Router (pages/ directory) uses getServerSideProps/getStaticProps and the older routing model. Next.js still supports Pages Router, but recommends App Router for new applications.

When should you use Server Components vs Client Components in Next.js?

Use Server Components (default) for: data fetching, accessing backend resources, keeping sensitive data on server, reducing client JavaScript. Use Client Components ('use client') for: interactivity and event handlers, useState/useEffect hooks, browser APIs, third-party libraries that use client features. Start with Server Components and only add 'use client' when you need interactivity.

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

In App Router, Server Components can await fetch() or call a database directly. Since Next.js 15, fetch is not cached by default: opt in with cache: 'force-cache' or next: { revalidate: seconds }. With Cache Components enabled in Next.js 16, cache components or functions explicitly with 'use cache', cacheLife(), and cacheTag().

What is ISR (Incremental Static Regeneration) and when should you use it?

ISR regenerates previously prerendered content after deployment. In the previous caching model, use next: { revalidate: seconds } or a route-level revalidate export. With Next.js 16 Cache Components, put 'use cache' in the cached scope and configure cacheLife(). Use on-demand tags when updates are event-driven.

What are Server Actions in Next.js?

Server Actions are async Server Functions marked with 'use server'. They are suited to forms and mutations initiated by your own UI, integrate with useActionState/useFormStatus, progressive enhancement, redirects, and cache revalidation. They do not replace Route Handlers for public APIs, webhooks, or arbitrary HTTP clients.

How does Next.js caching work?

Do not memorize one universal four-layer model without naming the configuration. Next.js 16 supports the previous Data Cache/Full Route Cache model and the opt-in Cache Components model. Cache Components uses 'use cache', cacheLife(), cacheTag(), Partial Prerendering, and a client Router Cache; dynamic work stays request-time by default.

Ready to ace your interview?

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

View PDF Guides