interviewDeck

Your one-stop platform to prepare, practice and ace your interviews.

Loading your questions…

All Questions

Filters & tools

Next.js Interview Questions and Answers

24 hand-picked Next.js interview questions with detailed answers. Open the interactive version above to search, filter by difficulty, run code, bookmark questions and track your progress.

What does Next.js give you over a plain React SPA?

React is a view library; Next.js is the framework around it. Out of the box you get:

  • Server rendering and static generation — HTML on first load, so content is visible before JS executes and crawlers see real markup.
  • File-system routing with nested layouts, loading and error states.
  • A backend — Route Handlers and Server Actions, so you can talk to a database without a separate service.
  • Built-in optimisationnext/image, next/font, automatic code splitting, prefetching of links in the viewport.
  • A caching system across requests, data and routes.
  • React Server Components — components that run only on the server and ship no JS.

The trade: you now run a Node server (or an edge runtime), your mental model spans server and client, and the caching layers are genuinely subtle. A purely internal dashboard behind a login often doesn't need any of it — a Vite SPA is simpler and faster to build.

App Router vs Pages Router — what changed?

Pages RouterApp Router
Directorypages/app/
Default componentClient componentServer component
Data fetchinggetServerSideProps, getStaticPropsasync components, fetch directly
LayoutsSingle _app.js, remountsNested layout.tsx, preserve state
Loading/errorManualloading.tsx, error.tsx conventions
StreamingLimitedBuilt in, via Suspense
MutationsAPI routesServer Actions

The App Router is the recommended default for new projects; Pages Router remains supported and both can coexist in one app, which is how large migrations are actually done — route by route.

Server Components vs Client Components — how do you decide?

Server Components (the default) run only on the server. They can read the database or filesystem directly, use secrets, and ship zero JavaScript to the browser — the client receives rendered output, not the component code. They cannot use state, effects, event handlers or browser APIs.

Client Components ('use client' at the top of the file) are pre-rendered on the server then hydrated in the browser. They can do everything React normally does, and their code is in the bundle.

The decision rule: server by default; go client only when you need useState/useEffect, an event handler, a browser API, or a library that uses one.

The pattern that matters: push 'use client' as far down the tree as possible. Don't mark a page client just because it contains one interactive button — extract the button. And you can pass Server Components as children into a Client Component, so an interactive shell can wrap server-rendered content.

// app/page.tsx  — Server Component (default)
import { db } from '@/lib/db';
import LikeButton from './like-button';   // client

export default async function Page() {
  const posts = await db.post.findMany();  // direct DB access, no API layer
  return posts.map(p => (
    <article key={p.id}>
      <h2>{p.title}</h2>
      <LikeButton postId={p.id} />        {/* only this ships JS */}
    </article>
  ));
}

What are the rules for passing data across the server/client boundary?

Props crossing from a Server Component into a Client Component are serialised, so they must be serialisable: primitives, plain objects, arrays, Date, Map, Set, and Server Actions. Functions, class instances and JSX-producing callbacks are not — passing one is a runtime error.

Other rules that trip people up:

  • A Client Component cannot import a Server Component — but it can receive one as a child. That's the composition escape hatch.
  • Everything a 'use client' file imports becomes part of the client bundle, transitively.
  • Server-only secrets must be protected: use the server-only package so importing a server module from client code fails at build time, not silently at runtime.
  • Anything you pass as a prop is visible in the RSC payload sent to the browser — so don't pass a whole user record when the client needs a name.
// ✗ error: functions aren't serialisable
<ClientChart formatter={(v) => `£${v}`} />

// ✓ pass data, keep the function on the client
<ClientChart data={rows} currency="GBP" />

// ✓ Server Component as a child of a Client Component
<ClientTabs>
  <ServerRenderedPanel />
</ClientTabs>

Static, dynamic, ISR, PPR — which rendering strategy when?

  • Static (SSG) — rendered at build time, served from the CDN. Fastest and cheapest. Use for marketing pages, docs, blogs — anything the same for every user.
  • ISR (Incremental Static Regeneration) — static, but revalidated after a set time or on demand. The sweet spot for content that changes occasionally: e-commerce listings, articles, pricing.
  • Dynamic (SSR) — rendered per request. Needed for personalised or request-dependent content (dashboards, authenticated views, anything reading cookies or headers).
  • Client-side — fetched in the browser after load. Right for highly interactive, private, frequently-changing data where SEO is irrelevant.
  • PPR (Partial Prerendering) — a static shell served instantly from the CDN with dynamic holes streamed in. Aims to give you static performance and dynamic content in one route.

In the App Router this is mostly inferred: a route is static until you use something dynamic (cookies(), headers(), searchParams, or an uncached fetch), which opts it into dynamic rendering. You can force it with route segment config.

// force behaviour per route segment
export const dynamic = 'force-static';   // or 'force-dynamic'
export const revalidate = 3600;          // ISR: regenerate at most hourly

How does ISR work, and how do you revalidate on demand?

Incremental Static Regeneration serves a cached static page and refreshes it in the background using stale-while-revalidate: the first request after the revalidate window still gets the stale page instantly, a regeneration is triggered, and subsequent visitors get the fresh one. Nobody waits for a rebuild.

Time-based: export const revalidate = 3600 on the route, or per-fetch with next: { revalidate: 3600 }.

On-demand is usually what you actually want — publish in the CMS, page updates in seconds instead of waiting out a timer:

  • revalidatePath('/blog/my-post') — invalidate a specific route.
  • revalidateTag('posts') — invalidate every fetch tagged posts, across all routes. Cleaner for content that appears in several places.

Call these from a webhook Route Handler (authenticate it) or from a Server Action after a mutation.

// fetch tagged for later invalidation
const res = await fetch(url, { next: { tags: ['posts'], revalidate: 3600 } });

// app/api/revalidate/route.ts  — CMS webhook
export async function POST(req: Request) {
  if (req.headers.get('x-secret') !== process.env.REVALIDATE_SECRET)
    return new Response('Unauthorized', { status: 401 });
  revalidateTag('posts');
  return Response.json({ revalidated: true });
}

How do you fetch data in the App Router?

Server Components are async, so you just await — no getServerSideProps, no useEffect, no loading state boilerplate.

Caching control (Next 15 defaults fetch to no store; earlier versions cached aggressively, which was a common source of confusion — always check your version):

  • { cache: 'force-cache' } — cache indefinitely until revalidated.
  • { next: { revalidate: 60 } } — time-based ISR.
  • { cache: 'no-store' } — fetch fresh every request.

Waterfalls are the main performance trap: sequential awaits in one component serialise the requests. Use Promise.all for independent data, and prefer fetching in the component that needs it rather than threading props down — request memoisation deduplicates identical fetches within the same render pass, so two components asking for the same user only cause one request.

For client-side data (mutating, polling, infinite lists) use SWR or TanStack Query as normal.

export default async function Page() {
  // ✓ parallel — independent requests
  const [user, posts] = await Promise.all([getUser(), getPosts()]);
  return <Profile user={user} posts={posts} />;
}

What are the caching layers in Next.js App Router?

Four caches, and knowing which one is serving you stale data is most of the debugging:

  1. Request Memoization — dedupes identical fetch calls within a single render pass. Server-side, per request, automatic. Not configurable.
  2. Data Cache — persists fetch results across requests and deployments. Controlled by cache/next.revalidate, invalidated by revalidateTag/revalidatePath.
  3. Full Route Cache — the rendered HTML and RSC payload of a static route, stored at build/revalidation time. Skipped entirely for dynamic routes.
  4. Router Cache — client-side, in memory: visited route payloads kept for fast back/forward and prefetching. Cleared on router.refresh(), on a Server Action, or on full reload.

Debugging heuristic: stale after a deploy → Data Cache or Full Route Cache. Stale only while navigating in the tab but correct on reload → Router Cache. That distinction saves hours.

What are the special files in the app directory?

  • page.tsx — makes a route publicly accessible. Without it, a folder is just structure.
  • layout.tsx — wraps its segment and everything below. Nested layouts compose, and crucially they preserve state and don't re-render on navigation within their segment.
  • template.tsx — like a layout but creates a new instance per navigation, so state resets and enter animations replay.
  • loading.tsx — an automatic Suspense boundary; shown instantly while the segment streams in.
  • error.tsx — an error boundary for the segment (must be a Client Component); receives reset() to retry.
  • not-found.tsx — rendered by notFound() or for unmatched routes.
  • route.ts — an API endpoint instead of a page (can't coexist with page.tsx in the same segment).
  • global-error.tsx — catches errors in the root layout itself.

Organisational conventions: (group) folders organise without affecting the URL, _folder opts out of routing entirely, and @folder defines a parallel route slot.

How do dynamic routes and generateStaticParams work?

Segment syntax:

  • [id] — one segment: /posts/123.
  • [...slug] — catch-all, one or more segments: /docs/a/b/c.
  • [[...slug]] — optional catch-all, also matches the bare parent route.

The values arrive as params in the page or layout. In Next 15 params and searchParams are Promises and must be awaited — a very common upgrade break.

generateStaticParams tells Next which values to pre-render at build time, turning a dynamic route into many static pages. Combine it with dynamicParams: true (default) renders unknown values on demand and caches them; false makes anything not in the list a 404 — useful when the set is genuinely fixed.

// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getTopPosts(200);
  return posts.map(p => ({ slug: p.slug }));   // pre-rendered at build
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;               // Next 15: params is a Promise
  const post = await getPost(slug);
  if (!post) notFound();
  return <Article post={post} />;
}

What are Server Actions and what should you be careful about?

Functions marked 'use server' that run on the server but can be called directly from client code — Next creates the endpoint and the fetch for you. They can be wired straight to a form's action, so mutations work without JavaScript.

Benefits: no hand-written API route for every mutation, progressive enhancement for free, type safety end to end, and revalidatePath/revalidateTag to refresh data in the same call.

What to be careful about:

  • Every Server Action is a public HTTP endpoint. It must authenticate and authorise itself — being defined next to a protected component proves nothing.
  • Validate the input (Zod or similar). FormData is entirely attacker-controlled.
  • They execute sequentially, not in parallel — not a fit for high-frequency calls.
  • Not a general API — mobile clients and third parties still need Route Handlers.
  • Use useActionState / useFormStatus for pending and error states, and useOptimistic for instant feedback.
'use server';

export async function updateProfile(prevState: State, formData: FormData) {
  const session = await auth();                       // 1. authenticate
  if (!session) return { error: 'Unauthorized' };

  const parsed = ProfileSchema.safeParse({            // 2. validate
    name: formData.get('name'),
  });
  if (!parsed.success) return { error: 'Invalid name' };

  await db.user.update({                              // 3. authorised write
    where: { id: session.user.id },                   //    scoped to THIS user
    data: parsed.data,
  });
  revalidatePath('/profile');
  return { ok: true };
}

When do you use a Route Handler instead of a Server Action?

Route Handlers (app/api/…/route.ts) are real HTTP endpoints exporting GET, POST, PUT, DELETE etc., built on the standard Request/Response Web APIs.

Use a Route Handler when:

  • An external consumer calls it — a mobile app, a partner, a third-party integration.
  • You need a webhook receiver (Stripe, a CMS, GitHub) — those senders speak HTTP, not Server Actions.
  • You need full control of the response — custom status codes, headers, streaming, non-JSON bodies, file downloads, OG images.
  • You need GET semantics with HTTP caching, or CORS.

Use a Server Action when it's your own UI mutating your own data.

Note that a Route Handler in the App Router doesn't parse the body for you — you call await req.json() or await req.formData() yourself.

// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature')!;
  const body = await req.text();                     // raw body for signature check
  let event;
  try { event = stripe.webhooks.constructEvent(body, sig, SECRET); }
  catch { return new Response('Bad signature', { status: 400 }); }

  await handle(event);
  return Response.json({ received: true });
}

How does streaming work in the App Router?

Instead of waiting for all server data before sending anything, Next streams HTML as it becomes ready. The shell and fast content paint immediately; slow sections arrive later and are slotted in.

Two ways to opt in:

  • loading.tsx — an implicit Suspense boundary around the whole route segment. One line, coarse-grained.
  • Explicit <Suspense> — wrap individual slow components with their own fallbacks, so a slow recommendations widget doesn't hold up the product details.

Why it matters: TTFB and FCP stop being hostage to your slowest query. A page with one 2-second call and three fast ones renders in ~100 ms with one skeleton, rather than blocking for 2 s.

Caveats: once streaming has begun the status code and headers are already sent, so you can't change them mid-stream; each boundary is a layout-shift risk, so fallbacks should approximate final dimensions; and a fetch outside any Suspense boundary still blocks the whole response.

export default function Page() {
  return (
    <>
      <ProductDetails />                       {/* fast, renders immediately */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews />                            {/* slow, streams in later */}
      </Suspense>
    </>
  );
}

What does next/image do for you?

It handles the entire image optimisation checklist automatically:

  • Format negotiation — serves AVIF/WebP to browsers that support them.
  • Responsive resizing — generates srcset from your configured device sizes, so phones don't download desktop images.
  • Lazy loading by default, with priority to opt the LCP image out.
  • Reserved space — requires width/height (or fill), which prevents CLS by construction.
  • Placeholdersplaceholder="blur", automatic for static imports.
  • Caching of optimised variants.

Things to get right: set priority on the hero image (this is the most common LCP mistake), configure remotePatterns for external hosts (it's an allowlist for security — an open optimiser is an abuse vector), use sizes whenever you use fill or a responsive layout, and remember that on self-hosted deployments the optimiser runs on your server and needs sharp.

<Image
  src={hero}                 // static import -> blur placeholder for free
  alt="Product hero"
  priority                   // it's the LCP element: no lazy loading
  sizes="(max-width: 768px) 100vw, 50vw"
/>

How do next/font and next/script help performance?

next/font — fonts are downloaded at build time and self-hosted, so there's no request to Google Fonts at runtime: one less DNS+TLS handshake and no third-party dependency. It also generates the CSS with font-display, preloads automatically, and — importantly — computes a metric-matched fallback so the font swap causes zero layout shift.

next/font/local does the same for your own font files. Declare fonts at module scope, not inside a component, so they aren't re-created on each render.

next/script — controls when third-party scripts load:

  • beforeInteractive — blocking, before hydration. Almost never needed (bot detection, polyfills).
  • afterInteractive (default) — after hydration. Analytics, tag managers.
  • lazyOnload — during idle time. Chat widgets, social embeds — where most third-party scripts belong.
  • worker (experimental) — off the main thread via Partytown.
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });  // module scope

export default function RootLayout({ children }) {
  return <html className={inter.className}><body>{children}</body></html>;
}

What is Next.js middleware good for, and what are its limits?

middleware.ts runs before a request is completed, on the edge runtime, for every matching route. Good uses:

  • Redirects and rewrites — locale routing, legacy URL maps, A/B splitting.
  • A cheap auth gate — check a session cookie exists and redirect to login if not.
  • Setting headers and cookies, geolocation-based behaviour, bot filtering.

Limits that matter:

  • It runs on the Edge runtime: no Node APIs, no filesystem, no native modules, and many database drivers won't work. Bcrypt and most ORMs are out.
  • It runs on every matched request, so it's on the critical path — use matcher to scope it tightly and keep it fast.
  • Its size is limited, and cold starts are real.

Security point: middleware is a UX gate, not authorisation. A cookie's presence isn't a valid session. Always re-check auth in the page, Server Action or Route Handler that actually touches data.

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],   // scope it tightly
};

export function middleware(req: NextRequest) {
  const token = req.cookies.get('session');
  if (!token) return NextResponse.redirect(new URL('/login', req.url));
  return NextResponse.next();   // real verification happens in the page/action
}

How do you handle authentication in the App Router?

The mental shift is that there are now several places code runs, and each one must check for itself.

  • Session storage — an HttpOnly, Secure, SameSite cookie, read server-side with cookies(). Libraries: Auth.js (NextAuth), Clerk, Lucia, or your own.
  • Server Components — call your auth() helper and redirect or render an unauthorised state. Because this runs on the server, no protected data ever reaches the client.
  • Server Actions and Route Handlersmust re-check. They're independently callable public endpoints.
  • Middleware — optional fast redirect for a better UX; not the security boundary.

Data Access Layer pattern: put auth checks in the functions that read data, not in the components. Then a forgotten check in one page can't leak, because the query itself refuses.

Note: using cookies() or headers() makes the route dynamic, which is expected for authenticated pages.

// lib/dal.ts — checks live with the data, not the UI
import 'server-only';

export async function getInvoices() {
  const session = await auth();
  if (!session) redirect('/login');
  return db.invoice.findMany({ where: { userId: session.user.id } });  // scoped
}

How does error handling work in the App Router?

  • error.tsx — an error boundary for its segment. Must be a Client Component (it uses React error boundaries), receives { error, reset }, and reset() re-renders the segment to retry. It catches errors in its children, not in its own layout.
  • global-error.tsx — catches errors thrown by the root layout itself. It replaces the whole document, so it must render its own <html> and <body>.
  • not-found.tsx — rendered when you call notFound(), which is the idiomatic way to 404 a missing resource.
  • Expected errors (validation failure, wrong password) shouldn't throw — return them as values from Server Actions and render them, so the user gets a useful message instead of a boundary.

Security note: error messages from Server Components are redacted in production and replaced with a digest, deliberately — the real message is in your server logs. Don't try to display it.

'use client';

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  useEffect(() => { logToService(error); }, [error]);
  return (
    <div role="alert">
      <h2>Something went wrong loading reports.</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

What causes hydration errors and how do you fix them?

A hydration error means the HTML React rendered on the server doesn't match what it rendered on the client. React then discards the server HTML and re-renders — you lose the SSR benefit and often see a flash.

Usual causes:

  • Non-deterministic valuesDate.now(), new Date().toLocaleString(), Math.random(), crypto.randomUUID() rendered directly.
  • Browser-only APIs during renderwindow, localStorage, matchMedia. The server has no idea what they'd return.
  • Locale and timezone differences between server and client formatting — a very common one.
  • Invalid HTML nesting<div> inside <p>, <p> inside <p>. The browser silently repairs it, so the DOM no longer matches React's tree.
  • Browser extensions injecting attributes (Grammarly, dark-mode extensions) — mostly harmless noise, and suppressHydrationWarning on the affected element is the accepted fix.

The fix pattern: render client-only values in useEffect after mount, or dynamically import the component with ssr: false. Format dates with a fixed locale and timezone.

// client-only value: render nothing on the server, fill in after mount
const [time, setTime] = useState<string | null>(null);
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <span suppressHydrationWarning>{time ?? '—'}</span>;

How do you handle SEO metadata in Next.js?

Two mechanisms in the App Router:

  • Staticexport const metadata from a layout or page. Metadata from nested segments merges, with children overriding parents, so a root layout can set defaults and templates.
  • Dynamicexport async function generateMetadata({ params }), which can fetch. Its fetches are deduplicated against the page's own, so titling a page from its data costs nothing extra.

File conventions cover the rest: sitemap.ts, robots.ts, opengraph-image.tsx (generate OG images at build or request time with ImageResponse), icon/apple-icon, and manifest.ts.

Also worth doing: alternates.canonical to avoid duplicate-content issues, structured data (JSON-LD) injected as a script tag in the page, and a title.template so every page gets consistent branding.

Note: metadata only works in Server Components — a 'use client' page can't export it.

export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);        // deduped with the page's fetch
  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: { title: post.title, images: [post.cover] },
  };
}

How do environment variables work in Next.js?

Two categories, and mixing them up is a security bug:

  • Server-only — any variable without the prefix. Available in Server Components, Server Actions, Route Handlers and middleware. Never sent to the browser.
  • NEXT_PUBLIC_*inlined into the client bundle at build time. Fully public: anyone can read it in DevTools. The prefix is a deliberate signal, not a convenience.

Consequences of build-time inlining: changing a NEXT_PUBLIC_ value requires a rebuild, not just a restart — so the same Docker image can't be promoted across environments with different public config. If you need that, fetch runtime config from an endpoint or render the value from a Server Component instead.

File order: .env.local (never committed, wins) → .env.production/.env.development.env.

What are the deployment options and runtime choices?

Hosting:

  • Vercel — every feature works, zero config, and ISR/image optimisation/edge functions are first-class. The default choice.
  • Self-hosted Nodenext build && next start, or output: 'standalone' for a small Docker image. Everything works, but you own caching, scaling and the image optimiser (needs sharp). Multi-instance deployments need a shared cache handler or ISR results diverge per pod.
  • Static export (output: 'export') — plain HTML/CSS/JS to any static host, but you lose SSR, ISR, Server Actions, middleware and image optimisation.
  • Other platforms (Netlify, AWS Amplify, Cloudflare, OpenNext) — supported via adapters, usually with some feature caveats.

Runtime per route: nodejs (default — full Node APIs, most compatible) vs edge (fast cold starts, runs near the user, but a restricted API surface and small size limits). Middleware is edge-only.

How do you optimise a slow Next.js app?

Server side:

  • Move work to build time — static or ISR instead of dynamic wherever content allows.
  • Kill request waterfalls with Promise.all, and stream slow sections behind <Suspense> so they don't block first paint.
  • Check nothing accidentally opted the route into dynamic rendering (a stray cookies() in a shared component makes the whole route dynamic).

Client side:

  • Shrink the client boundary — the biggest Next-specific win. Audit where 'use client' sits; moving it down the tree can remove large libraries from the bundle entirely.
  • next/dynamic for heavy components (charts, editors, maps).
  • @next/bundle-analyzer to find what's actually in there.
  • next/image, next/font, and lazyOnload for third-party scripts.

Also Next-specific: a large RSC payload (passing huge props into client components) is invisible in bundle analysis but very much on the wire.

Next.js vs Remix vs Astro vs a Vite SPA — how do you choose?

  • Next.js — the default for React apps needing SSR/SSG, the largest ecosystem, RSC and the richest caching model. Cost: complexity and a strong pull toward Vercel for the smoothest experience.
  • Remix / React Router 7 — web-standards oriented (Request/Response, forms, nested routes with loaders and actions). Simpler mental model, less caching machinery, excellent progressive enhancement.
  • Astro — content-first with islands architecture: ships zero JS by default and lets you mix React, Vue and Svelte components. The best answer for marketing sites, blogs and docs.
  • Vite SPA — no server, deploy anywhere static. Right for authenticated dashboards and internal tools where SEO and first paint don't matter and simplicity does.
  • TanStack Start — newer, type-safe full-stack React on Vite; worth naming as an emerging option.

The decision drivers: do you need SEO or fast first paint for anonymous users? Is content mostly static? Do you want to run a server at all? Answer those three and the choice usually makes itself.