The 60 Next.js questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Next.js is an open-source React framework built by Vercel that gives React apps a routing system, server rendering, data fetching, and a build pipeline out of the box. Instead of wiring together a router, a bundler, and a rendering strategy yourself, you get file-based routing and a choice of how each route renders: on the server per request, prebuilt at build time, revalidated on a schedule, or in the browser. Since version 13 the App Router made React Server Components the default, which shifts data fetching to the server and shrinks the JavaScript sent to the client. In interviews, Next.js questions probe the rendering model (Server versus Client Components, SSR versus SSG versus ISR), the caching layers, and routing conventions, not memorized API names. This page collects the 60 questions that come up most, each with a direct answer and runnable code. If you're building fundamentals, the official Next.js documentation from Vercel is the canonical path; increasingly the first frontend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Next.js for Beginners - Full Course
Video: Next.js for Beginners - Full Course (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Next.js certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Next.js is a React framework built by Vercel. React itself handles the UI components; Next.js adds the parts React leaves out of the box: file-based routing, server rendering, static generation, data fetching, image and font optimization, API routes, and a full build pipeline that ties them together.
In plain terms, React is a library and Next.js is a full framework around it. You still write React components, but Next.js decides how each route renders and how it reaches the browser.
Key point: A clean one-line split (React is the library, Next.js is the framework) plus one concrete thing Next.js adds beats a feature dump. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Next.js in 100 Seconds // Plus Full Beginner's Tutorial (Fireship, YouTube)
Plain React with a client-only build ships an empty HTML shell and renders everything in the browser, which hurts SEO and first paint. Next.js can render on the server or prebuild pages, so users and crawlers get real HTML immediately.
You also get file-based routing, image and font optimization, API routes, and data fetching built in, so you skip assembling a router, a bundler config, and a rendering strategy by hand.
The Pages Router is the original system: routes live in the pages/ directory, and data fetching uses getServerSideProps and getStaticProps. The App Router, added in Next.js 13, uses the app/ directory, React Server Components by default, and fetch-based data loading inside components.
The App Router is the current default and adds layouts, streaming, and Server Actions. New projects should use it; the Pages Router still runs in plenty of production apps.
| Pages Router | App Router | |
|---|---|---|
| Directory | pages/ | app/ |
| Default component | Client-rendered React | Server Component |
| Data fetching | getServerSideProps, getStaticProps | async components, fetch() |
| Layouts | Manual (_app, _document) | Nested layout.js files |
Key point: Say the App Router is the default now, then The thing it adds (layouts or streaming). Knowing both without dismissing the Pages Router indicates real experience.
Routes come from the file system, not a config file you maintain by hand. In the App Router, each folder under app/ becomes a URL segment, and adding a page.js file makes that segment publicly routable. So app/about/page.js serves /about with no route registration anywhere.
Nesting folders nests routes, so app/blog/[slug]/page.js serves /blog/anything. There's no manual route table to keep in sync.
app/
page.js -> /
about/page.js -> /about
blog/page.js -> /blog
blog/[slug]/page.js -> /blog/:slugFour main ones. Static Generation (SSG) prebuilds HTML at build time. Server-side rendering (SSR) renders fresh on every request. Incremental Static Regeneration (ISR) rebuilds static pages on a schedule after deploy without a full rebuild. Client-side rendering (CSR) renders in the browser. The point is choosing one per route, not per app.
A marketing page can be static, a dashboard can render per request, and a product page can use ISR so it stays fresh without a full rebuild.
| Method | Renders | Best for |
|---|---|---|
| SSG | At build time | Blogs, docs, marketing pages |
| SSR | On each request | Personalized or fast-changing pages |
| ISR | Rebuilt on a schedule | Product pages, large catalogs |
| CSR | In the browser | Highly interactive dashboards |
Key point: The all four and give one use case each. The follow-up is 'which would you pick for X and why', so each method maps to a concrete page.
Watch a deeper explanation
Video: NextJS Tutorial - All 12 Concepts You Need to Know (ByteGrad, YouTube)
SSG (Static Generation) builds the HTML once at build time and serves the same file to everyone, so it's fast and cacheable on a CDN. SSR (Server-side rendering) builds the HTML on the server for every request, so each user can get personalized, up-to-date content.
SSG suits content that's the same for all users and changes rarely. SSR suits pages that depend on the request: the logged-in user, cookies, or data that must be current.
Key point: The trap is calling SSR 'always better because it's fresh'. Show you know SSG's CDN speed and lower server cost are why static wins when content doesn't change per user.
ISR lets a statically generated page rebuild itself in the background after deployment, without a full rebuild of the whole site. You set a revalidation interval, and after it passes the next request triggers a regeneration while still serving the old page.
So you get static speed plus fresh content. It's the answer to 'my catalog has 50,000 pages and I can't rebuild them all on every price change'.
// App Router: revalidate this route every 60 seconds
export const revalidate = 60;
export default async function Page() {
const data = await getProducts();
return <ProductList products={data} />;
}Server Components render on the server, can fetch data directly, and send no JavaScript to the browser for themselves. Client Components render in the browser and can use state, effects, and event handlers. In the App Router, components are Server Components by default.
You add "use client" at the top of a file to make it a Client Component. The rule of thumb: keep components on the server unless they need interactivity or browser APIs.
| Server Component | Client Component | |
|---|---|---|
| Runs on | Server | Browser |
| Ships JS | No (for itself) | Yes |
| useState / useEffect | No | Yes |
| Direct data fetch | Yes (async component) | Via fetch in effects or hooks |
Key point: The default is Server Components. the key signal is that plus 'I only reach for "use client" when I need interactivity or a browser API', which shows you keep the client bundle small.
Watch a deeper explanation
Video: Introduction to Next.js and React (leerob, YouTube)
The "use client" directive at the top of a file marks the boundary where Client Components begin. That component and every component it imports below become part of the client JavaScript bundle and can use state, effects, refs, and event handlers. It's a boundary marker, not a per-component tag.
Once you cross that boundary, everything imported below is client too. Put it as low in the tree as possible to keep server rendering for the rest of the page.
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}layout.js defines UI that wraps a route segment and all its child routes: navbars, sidebars, and shared shells. It persists across navigations within that segment and doesn't re-render when you move between child pages, so state in the layout survives. page.js, by contrast, defines the unique content of one single route.
page.js defines the unique content of a single route. A layout can have many pages under it. The root layout is required and holds the html and body tags.
// app/dashboard/layout.js
export default function DashboardLayout({ children }) {
return (
<section>
<Sidebar />
<main>{children}</main>
</section>
);
}The App Router uses reserved filenames inside a route folder to add behavior. page.js is the route's content, layout.js is the shared wrapper, loading.js is a Suspense fallback shown while the segment loads, and error.js is an error boundary for that segment.
There's also not-found.js for 404s, route.js for API endpoints, and template.js for layouts that should re-render on navigation. Each file name is a convention Next.js wires up for you.
Wrap a folder name in square brackets. A folder like app/blog/[slug]/page.js matches /blog/anything, and the matched value arrives in the params prop passed to the page. For catch-all segments use [...slug], and for an optional catch-all that also matches the base path use [[...slug]].
The dynamic value is read from params, which is passed to the page. That's how a single file serves thousands of URLs.
// app/blog/[slug]/page.js
export default async function Post({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.title}</article>;
}Key point: Know the three shapes: [slug], [...slug] catch-all, and [[...slug]] optional catch-all. Interviewers ask you to route a nested path and expect the catch-all form.
next/image lazy-loads images by default, serves modern formats like WebP, generates responsive sizes for different screens, and requires an explicit width and height so the browser reserves space up front and the page doesn't jump as images arrive. That last part directly protects your Cumulative Layout Shift score.
A raw img tag ships the full-size file, loads eagerly, and can cause the page to jump as images arrive. next/image handles all of that and is one of the easiest performance wins in a Next.js app.
import Image from "next/image";
<Image src="/hero.png" alt="Hero" width={800} height={400} priority />;In the App Router, add a route.js file inside app/api and export async functions named after HTTP methods: GET, POST, PUT, DELETE, and so on. Each function receives the incoming Request and returns a Response, usually built with the NextResponse.json helper for JSON payloads.
In the Pages Router, files under pages/api export a handler that receives req and res. Either way, the API runs on the server in the same project, so you don't need a separate backend for simple endpoints.
// app/api/health/route.js
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ status: "ok" });
}Make the component an async function and await your data directly inside it. There's no useEffect and no manual loading state to manage: the component simply renders on the server once the data resolves, and only the finished HTML reaches the browser. It's the single biggest ergonomic win of the App Router.
Next.js extends fetch with caching and revalidation options, so you control freshness with a single argument instead of a separate data layer.
export default async function Page() {
const res = await fetch("https://api.example.com/posts");
const posts = await res.json();
return <PostList posts={posts} />;
}Key point: The 'no useEffect' point is the one the key point is: async Server Components remove the client fetch-then-render dance for initial data.
Both are Pages Router functions. getStaticProps runs at build time and produces a static page (SSG). getServerSideProps runs on every request on the server and produces a server-rendered page (SSR). Each returns a props object passed to the page component.
They don't exist in the App Router, where async components and fetch options replace them. Interviewers ask this to check you can work in both routers.
// Pages Router
export async function getStaticProps() {
const data = await getData();
return { props: { data }, revalidate: 60 };
}Put variables in .env files. Anything without a prefix stays server-only and is never sent to the browser, so database URLs and API keys are safe there. Variables prefixed with NEXT_PUBLIC_ get inlined into the client bundle at build time and become readable in the browser, so that prefix is your explicit opt-in to expose a value.
Never prefix a real secret like a private API key with NEXT_PUBLIC_, because it will then ship to every visitor in plain text inside the JavaScript bundle.
Key point: The security rule plainly: NEXT_PUBLIC_ means public. The gotcha interviewers probe is a secret accidentally prefixed and leaked to the client.
Next.js supports CSS Modules (scoped .module.css files), global CSS imported once in the root layout, Tailwind CSS, CSS-in-JS libraries, and Sass out of the box. CSS Modules and Tailwind are the common defaults on new projects, because both scope styles cleanly so class names don't collide across files.
Global CSS can only be imported once in the root layout to avoid ordering conflicts. Component-level styles use CSS Modules so class names don't collide across files.
In the App Router, export a static metadata object or an async generateMetadata function from a page or layout. Next.js takes it and renders the title, description, Open Graph, Twitter, and other tags into the document head for you, so you never hand-write head tags per page.
Because Server Components render real HTML with these tags in place, crawlers see them without running JavaScript, which is a big reason Next.js suits SEO-sensitive pages.
export const metadata = {
title: "Product Page",
description: "Buy the thing.",
};
export default function Page() {
return <main>...</main>;
}Add a loading.js file to the route segment. Next.js automatically wraps that segment's page in a React Suspense boundary and shows loading.js instantly while the Server Component's data streams in, then swaps in the real content once it resolves. The user sees an immediate response instead of a frozen screen.
You can also use React Suspense directly around a slow component for finer control. Either way, the user sees an instant response instead of a blank screen.
// app/dashboard/loading.js
export default function Loading() {
return <p>Loading dashboard...</p>;
}Hydration is the process where React attaches event handlers and state to the server-rendered HTML that already arrived in the browser. The server sends static HTML first for a fast paint, then the JavaScript loads and hydration wires up interactivity, turning the static markup into a working React app.
Only Client Components hydrate, because only they carry interactivity. Server Components ship as HTML with no client JavaScript, so they never hydrate, which is how the App Router keeps bundles small.
Key point: hydration maps to bundle size: 'only Client Components hydrate' is the sentence that shows you understand why the App Router defaults to server rendering.
For candidates with working experience: rendering mechanics, caching, data fetching judgment, and the questions that separate users from understanders.
Server Actions are async functions marked with "use server" that run only on the server. You can call one directly from a form's action prop or an event handler, and Next.js handles the network round trip for you, no separate API route needed.
They fit mutations: form submissions, database writes, revalidating cached data after a change. Because they run on the server, secrets and database clients stay off the client.
// app/actions.js
"use server";
import { revalidatePath } from "next/cache";
export async function addTodo(formData) {
await db.todos.create({ text: formData.get("text") });
revalidatePath("/todos");
}Key point: the question needs the boundary: Server Actions replace boilerplate API routes for mutations, and revalidatePath or revalidateTag after the write is what refreshes the UI.
Watch a deeper explanation
Video: Next.js App Router: Routing, Data Fetching, Caching (Vercel, YouTube)
There are four. The Request Memoization cache dedupes identical fetches within one render. The Data Cache persists fetch results across requests and deploys. The Full Route Cache stores rendered HTML and RSC payload for static routes. The Router Cache holds visited routes in the browser for instant back and forward navigation.
The two you tune most are the Data Cache (via fetch options and revalidate) and the Full Route Cache (static versus dynamic rendering). Knowing which cache a stale-data bug lives in is the real skill.
| Cache | Location | What it stores |
|---|---|---|
| Request Memoization | Server, per request | Duplicate fetches in one render |
| Data Cache | Server, persistent | fetch results across requests |
| Full Route Cache | Server, build/persistent | Rendered HTML and RSC payload |
| Router Cache | Client, in-memory | Visited routes for fast navigation |
Key point: Naming all four and then saying which one you'd suspect for a specific stale-data bug is what separates production-ready answers from a memorized list.
Pass options to fetch. cache: 'force-cache' caches the result indefinitely. cache: 'no-store' opts out and refetches on every request, which also makes the whole route dynamic. next: { revalidate: 60 } caches the result but revalidates after 60 seconds, which is time-based ISR at the data level rather than the whole route.
For on-demand invalidation, tag a fetch with next: { tags: ['posts'] } and call revalidateTag('posts') after a mutation. That refreshes only what changed instead of a whole route.
// time-based
await fetch(url, { next: { revalidate: 60 } });
// tag-based, invalidate later with revalidateTag('posts')
await fetch(url, { next: { tags: ['posts'] } });
// always fresh (makes the route dynamic)
await fetch(url, { cache: 'no-store' });By default a route is static: it renders once at build time and gets cached. It flips to dynamic automatically the moment you touch anything request-specific, like the cookies(), headers(), or searchParams APIs, or when a fetch inside it opts out of caching with cache: 'no-store'. You don't declare it; usage decides.
You can force it either way with export const dynamic = 'force-dynamic' or 'force-static'. The mental model: touch anything request-specific and the route can't be prebuilt, so Next.js renders it per request.
Key point: The insight the technical value is: reading cookies or headers silently flips a route to dynamic. Candidates who don't know this get confused when their 'static' page rebuilds every request.
Streaming sends HTML to the browser in chunks as each piece becomes ready, instead of blocking until the whole page is done. Fast, static parts show immediately, while slow data-dependent parts stream in afterward behind a Suspense fallback, so the user never stares at a blank screen waiting on the slowest query.
You wrap a slow component in Suspense with a fallback, or use loading.js for a whole segment. React streams the shell first, then flushes each Suspense boundary as its data resolves, so users see content sooner.
import { Suspense } from "react";
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<p>Loading feed...</p>}>
<SlowFeed />
</Suspense>
</>
);
}Route groups wrap a folder name in parentheses, like (marketing), to organize files or apply a shared layout without adding anything to the URL. So app/(shop)/cart/page.js still serves /cart. Parallel routes render several pages inside one layout at once using named slots such as @modal or @feed.
Parallel routes power patterns like a dashboard with independent panels or a modal that has its own route without leaving the current page.
Middleware is code in a middleware.js file that runs before a request finishes, at the edge, for the paths you match in its config. It can rewrite a URL, redirect, set headers, or read cookies. Common uses are auth gating, A/B routing, geolocation-based redirects, and adding security headers across many routes at once.
It runs in the Edge Runtime, so it's fast but restricted: no Node.js APIs, no heavy computation, no database calls. Keep it to routing decisions and quick checks.
import { NextResponse } from "next/server";
export function middleware(request) {
const token = request.cookies.get("session");
if (!token) return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
}
export const config = { matcher: "/dashboard/:path*" };Key point: The constraint is the point: middleware runs at the edge with no Node APIs. Candidates who try to do DB auth checks there get a follow-up on why it fails.
The Node.js runtime is the full server: all Node APIs, database drivers, heavier startup. The Edge Runtime is a lighter environment that runs close to users with fast cold starts but only a Web-standard API subset, no native Node modules.
Use the edge for latency-sensitive, lightweight work like middleware or simple API routes. Use Node for anything needing native modules, database clients, or larger dependencies.
| Edge Runtime | Node.js Runtime | |
|---|---|---|
| Cold start | Very fast | Slower |
| APIs available | Web standard subset | Full Node.js |
| Database drivers | Limited | Full support |
| Best for | Middleware, light routes | Heavy logic, DB access |
Use next/dynamic to load a component only when it's actually needed, which keeps it out of the initial JavaScript bundle and speeds up first load. It's the Next.js wrapper around React.lazy, and it adds convenient options like a loading fallback and a flag to disable server rendering for browser-only code.
Set ssr: false for components that depend on browser-only APIs (a charting library, a map) so they don't run during server rendering.
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./Chart"), {
loading: () => <p>Loading chart...</p>,
ssr: false,
});Fetch the data in the Server Component and pass it as props to the Client Component. The props are serialized and sent to the browser, so they must be serializable: plain objects, arrays, strings, numbers, not functions or class instances.
You can also compose the other way: pass a Server Component as children into a Client Component. That lets the server-rendered part stay on the server even inside a client wrapper.
Key point: The serialization boundary is the gotcha. If someone tries to pass a function or a Date-heavy class instance across it, that's the bug the interviewer is fishing for.
An error.js file creates a React error boundary scoped to its route segment. When a component in that segment throws during rendering, Next.js shows error.js instead of crashing the whole app, and it passes a reset function you can call to retry the render. error.js has to be a Client Component.
global-error.js catches errors in the root layout itself. For expected 'not found' cases, call notFound() to render not-found.js instead of throwing a generic error.
"use client";
export default function Error({ error, reset }) {
return (
<div>
<p>Something went wrong.</p>
<button onClick={reset}>Try again</button>
</div>
);
}generateStaticParams tells Next.js which dynamic route values to prebuild at build time. For a file like app/blog/[slug]/page.js, it returns the list of slugs, and Next.js statically generates a page for each one at build instead of rendering them on demand. It's the App Router replacement for the old getStaticPaths.
Paths you don't return can still render on first request and then cache, depending on your dynamicParams setting.
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((p) => ({ slug: p.slug }));
}next/font downloads Google Fonts at build time and self-hosts them, so there's no request to Google at runtime and no layout shift from a late-loading font. It also generates a fallback that matches the font's metrics to reduce shift further.
You import the font, call it with options, and apply its className. Local fonts work the same way through next/font/local. It's zero external requests and better privacy on top of performance.
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function Layout({ children }) {
return <body className={inter.className}>{children}</body>;
}A redirect sends the browser to a different URL: the address bar changes and the client makes a new request. A rewrite maps one URL to a different underlying path while keeping the original URL in the address bar: the user sees /blog but the app serves /articles.
Redirects suit moved pages and auth gating. Rewrites suit proxying, A/B setups, and clean public URLs that hide an internal structure. Both can live in next.config.js or in middleware.
| Redirect | Rewrite | |
|---|---|---|
| URL in browser | Changes | Stays the same |
| Extra client request | Yes | No |
| Typical use | Moved pages, auth gating | Proxying, clean URLs |
next.config.js controls build and runtime behavior for the whole app: allowed image domains and formats, redirects and rewrites, custom headers, environment variable exposure, webpack or Turbopack tweaks, the output mode like standalone or static export, and experimental feature flags. It runs at build and server start, not per request.
Because it's framework-level, route-specific behavior like a single page's caching belongs in the route files, not here.
Both invalidate cached data on demand after a mutation, usually called from a Server Action or a route handler. revalidatePath('/todos') purges the cache for one specific route path. revalidateTag('todos') instead purges every fetch that was tagged with 'todos', which can span many different routes at once. So one is path-scoped and the other is data-scoped.
Use revalidatePath when you know exactly which page changed. Use revalidateTag when the same data appears in several places and you want one call to refresh all of them.
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/todos"); // one route
revalidateTag("todos"); // every fetch tagged "todos"Fetch on the client for data that's user-specific and changes often after the page has loaded, or that depends on client-only interaction: live search results, infinite scroll, polling a job status, or anything that reacts to user input in real time. For that, a Client Component with SWR or React Query handles the caching and loading states.
Initial page data still belongs on the server for SEO and fast first paint. Client fetching is for the interactive layer that sits on top of that server-rendered shell.
Key point: The balanced answer is 'server for initial data, client for interactive updates'. Fetching everything on the client throws away the SSR benefit, and interviewers watch for that.
advanced rounds probe internals, architecture judgment, and production scars. Expect every answer here to draw a follow-up.
The server renders Server Components to a serialized description called the RSC Payload, not HTML strings. That payload describes the rendered tree and includes references to Client Components and their props. React on the client uses it to reconcile the tree and slot Client Components in.
So Server Component code and its dependencies never reach the browser; only the result does. Client Components arrive as references that the browser then loads and hydrates. This split is why the App Router can ship far less JavaScript than a client-rendered app.
Key point: The phrase to land is 'RSC Payload, not HTML'. Explaining that Server Component dependencies stay off the client is the depth this question screens for.
Watch a deeper explanation
Video: Learn Next.js 13 With This One Project (Web Dev Simplified, YouTube)
Partial Prerendering serves a static shell instantly and streams the dynamic parts into it within the same route. You wrap the dynamic pieces in Suspense; the static frame is prerendered and cached on a CDN, and the personalized holes fill in as they resolve.
It ends the all-or-nothing choice between a fully static page and a fully dynamic one. A product page can be static except for the cart badge and personalized price, without splitting into two rendering strategies.
Key point: Frame it as removing the static-versus-dynamic binary. the question needs to hear that one route can be mostly static with a few streamed dynamic holes.
Work through the caching layers in order. Is the fetch cached (force-cache with no revalidation)? Is the whole route in the Full Route Cache because it rendered static? Is the Router Cache in the browser serving an old client-side copy after navigation? Each layer has a different fix.
The tools: check the fetch's cache and revalidate options, confirm whether the route is static or dynamic, add revalidateTag or revalidatePath after the mutation, and remember router.refresh() clears the client Router Cache. Naming which layer is guilty before touching code is the strong move.
Key point: The layered diagnosis is (which cache, then verify, then fix), not whether you recall an exact API name.
Incrementally, because both routers coexist in the same project. Move routes one at a time: recreate the route under app/, convert getServerSideProps and getStaticProps to async component fetches, replace next/router imports with next/navigation, and split interactive pieces into "use client" components.
Do shared layouts and data fetching first since those pay off across many pages, and keep the Pages Router serving the rest until each route is verified. A big-bang rewrite of a large app is the failure mode; the routers running side by side is exactly what makes gradual migration possible.
Key point: The reassuring fact to lead with is that both routers run together. A candidate who plans a gradual, route-by-route move sounds far safer than one proposing a full rewrite.
Store the session in an HTTP-only cookie so client JavaScript can't read it. Check it in the layers that fit: middleware for fast route gating and redirects, Server Components and Server Actions for the actual data access checks, and a Data Access Layer function that every query calls so authorization lives in one place.
Don't rely on middleware alone for authorization, because it runs at the edge with limited capability and is a routing filter, not a security boundary. The real check belongs next to the data, in server code.
Key point: The senior distinction: middleware gates routes, but authorization must sit at the data layer. 'Middleware is not my security boundary' is the sentence that lands.
Measure first with the Next.js build output, which flags large routes and first-load JavaScript, plus Lighthouse or real Web Vitals in production and the bundle analyzer for JavaScript weight. Track the metrics that matter to users: LCP for load, CLS for layout stability, and INP for interaction responsiveness. Never optimize on a guess.
Then fix in order of impact: move components off the client to shrink JS, code-split heavy client bits with next/dynamic, use next/image and next/font, choose static or ISR over SSR where content allows, and cache data fetches. The pattern is measure, attribute, fix the biggest cost first.
A Server Action is a public HTTP endpoint even though it looks like a local function call. So you validate every input, check authentication and authorization inside the action itself, and never trust that it's only reachable from your own form. Anyone can invoke it.
Next.js adds protections like action ID obfuscation and origin checks, but those don't replace your own auth and validation. Treat each action exactly like an API route you're exposing to the internet.
Key point: The one-liner that scores: a Server Action is a public endpoint. Candidates who assume it's private because it reads like a function miss the whole threat model.
Centralize it in a Data Access Layer: functions that own the queries, the caching tags, and the authorization checks, so components call getUser() rather than fetching inline. That keeps auth in one place and makes cache invalidation a matter of tagging.
Fetch where the data is used (colocation) rather than prop-drilling from the top, and rely on Request Memoization to dedupe the same call across components in one render. Parallelize independent fetches with Promise.all instead of awaiting them in sequence.
// data access layer
import "server-only";
export async function getUser(id) {
const res = await fetch(`${API}/users/${id}`, { next: { tags: ["user"] } });
return res.json();
}A waterfall is when independent data fetches run one after another because each awaits the previous, even though neither needs the other's result. Total time becomes the sum instead of the max, and the page renders slower for no reason.
Fix it by starting independent fetches together and awaiting them with Promise.all, or by kicking off fetches early and awaiting later. Streaming with Suspense also hides unavoidable slow fetches by letting the rest of the page render first.
// waterfall: sequential, slow
const user = await getUser();
const posts = await getPosts();
// parallel: both start at once
const [user2, posts2] = await Promise.all([getUser(), getPosts()]);Key point: the question needs Promise.all for independent fetches. Show you know a waterfall is a self-inflicted latency bug, not an unavoidable cost.
Next.js runs anywhere Node runs. Set output: 'standalone' in the config to produce a minimal server bundle, wrap it in a Docker image, and run it on any container platform. For a fully static site with no server features, output: 'export' produces plain HTML you can host on any CDN.
The catch is that some features assume Vercel's infrastructure: ISR, image optimization, and the edge runtime need equivalents (a self-hosted image optimizer, a cache backend) when you host elsewhere. Naming those gaps is the experienced answer.
| Target | Config | Trade-off |
|---|---|---|
| Vercel | Default | All features, tied to platform |
| Docker / Node host | output: 'standalone' | Self-manage ISR and image optimization |
| Static CDN | output: 'export' | No SSR, ISR, or API routes |
Unit and component tests with Jest or Vitest plus React Testing Library for logic and rendering. End-to-end tests with Playwright or Cypress for real user flows across routes, which matters because so much Next.js behavior is routing and server rendering that unit tests can't cover.
Server Components need care: async server components aren't fully supported by every unit test runner yet, so E2E often covers server-rendered paths. Mock the network boundary and test Server Actions like the API endpoints they are.
Use a workspace tool like Turborepo with npm or pnpm workspaces, plus shared packages for UI, config, and utilities that several apps import. Turborepo caches build outputs and runs tasks in parallel across packages, which matters a lot as the repo grows and full rebuilds get slow. Keep app-specific code out of shared packages.
Keep the boundaries clean: shared packages export components and helpers, apps own their routes and env. The common mistake is a shared package that imports app-specific code, which tangles the dependency graph and breaks caching.
Streaming improves perceived performance: users see the shell fast and content fills in. The cost is more complex loading states, and once the response has started streaming you can't change the HTTP status code or headers, so a data error mid-stream can't become a clean 500 or redirect.
So put anything that must decide status or redirect (auth checks, not-found decisions) before the streaming boundary, and stream only the parts where a progressive fallback is acceptable. That ordering is the real skill.
Key point: The subtle point interviewers love: after streaming starts you can't set the status code. Deciding redirects and 404s before the first Suspense boundary is the takeaway.
A hydration error means the HTML React rendered on the server doesn't match what it renders on the client. Common causes: using Date.now() or Math.random() during render, reading window or localStorage in a component that also runs on the server, or invalid HTML nesting the browser silently fixes.
Fixes: move browser-only values into useEffect so they run after hydration, gate them behind a mounted flag, use suppressHydrationWarning only for genuinely dynamic content like timestamps, and fix nesting. The root rule is that server and client must render the same initial output.
Use next/script with a strategy prop instead of a raw script tag. beforeInteractive loads critical scripts before hydration, afterInteractive (the default) loads them just after, and lazyOnload waits until the browser goes idle, which suits low-priority things like chat widgets or analytics. Choosing the right strategy keeps heavy third-party code from blocking interactivity.
Analytics and support widgets belong on lazyOnload; a consent manager that has to run before everything else belongs on beforeInteractive.
import Script from "next/script";
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />;Turbopack is the Rust-based bundler Vercel built as webpack's successor for Next.js. It's designed for faster cold starts and incremental updates, and it's stable for development and increasingly for production builds. webpack is the long-standing default that Turbopack is replacing.
In interviews the useful point is why: JavaScript bundlers hit a ceiling on large apps, so moving the hot path to Rust and caching aggressively is how build times stay flat as the app grows.
Importing 'server-only' in a module makes the build fail if that module ever gets pulled into a Client Component. It's a guardrail: put it in files that touch secrets or database clients so they can't accidentally ship to the browser.
'client-only' is the mirror image, failing the build if server code imports a browser-only module. Together they turn a class of accidental-boundary-crossing bugs into build errors instead of runtime leaks.
import "server-only";
// This module now errors at build time if imported
// into any Client Component.
export async function queryDb() {
return db.query("...");
}Key point: This shows security-mindedness: 'server-only' turns a secret-leak risk into a build failure. it in practice matters.
Use Draft Mode. A route handler calls draftMode().enable(), which sets a cookie, and while that cookie is on, Next.js bypasses the Data Cache and renders those routes dynamically so editors see unpublished content instead of the cached published version. A second route handler calls disable() to turn it off again.
Inside components you read draftMode().isEnabled to fetch draft versus published content from the CMS. This is how a marketing team previews an unpublished page on the live site without exposing drafts to real visitors.
// app/api/draft/route.js
import { draftMode } from "next/headers";
export async function GET() {
(await draftMode()).enable();
return new Response("Draft mode on");
}Start from the data. Same for every user and rarely changing goes static (SSG). Same for everyone but updated often goes ISR with a revalidation window. Personalized or request-dependent goes dynamic (SSR). Highly interactive after load leans on Client Components fetching on top of a server-rendered shell.
Then refine: use Partial Prerendering to keep a mostly-static page with a few dynamic holes, and streaming to hide slow fetches. The senior habit is deriving the strategy from data freshness and personalization, not defaulting every page to SSR.
| Data pattern | Strategy |
|---|---|
| Same for all, rarely changes | Static (SSG) |
| Same for all, changes often | ISR with revalidate |
| Personalized / request-based | Dynamic (SSR) |
| Interactive after load | Server shell + client fetching |
Key point: The framework to voice: derive rendering from data freshness and personalization. Candidates who reason from the data instead of defaulting to SSR read as senior.
Next.js wins when you want a React app that renders on the server, generates static pages, and handles routing and data fetching without assembling those pieces yourself. It trades the freedom of a bare React setup for opinions: a file-based router, a built-in rendering pipeline, and tight coupling to Vercel's hosting model. Create React App shipped only a client bundle and is now deprecated, Remix leans harder into web fundamentals and nested loaders, and Gatsby centers on static builds with a GraphQL data layer. Knowing these trade-offs out loud is an interview signal: it shows you pick a framework on merits, not habit.
| Framework | Rendering focus | Routing | Best at |
|---|---|---|---|
| Next.js | SSR, SSG, ISR, client, RSC | File-based (App Router) | Full-stack React apps, mixed rendering |
| Create React App | Client only (deprecated) | Bring your own router | Legacy client SPAs |
| Remix | SSR with nested loaders | File-based nested routes | Form-heavy apps, web fundamentals |
| Gatsby | Static build (SSG) | File-based + GraphQL | Content sites with a data layer |
Prepare in layers, and practice out loud. Most Next.js rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Next.js interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Next.js questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works