Next.js Interview Questions and How to Answer Them (40+ Questions)
Next.js is the dominant React framework for production applications. If you have a frontend or full-stack interview coming up, this is the resource you need — 40+ real questions, detailed answers, and working code that mirrors what interviewers actually expect.
This guide is organized from foundational concepts through advanced topics so you can read start-to-finish or jump directly to the area you need to strengthen.
Foundational Concepts
1. What is Next.js and why would you use it over plain React?
Next.js is a React framework that adds server-side rendering (SSR), static site generation (SSG), file-based routing, API routes, image optimization, and built-in TypeScript support on top of React.
The core reasons to reach for Next.js over a plain React SPA:
- SEO — search engines can read server-rendered HTML immediately; a blank cannot be indexed until JavaScript executes
- Performance — pages can ship zero JavaScript for static content; React Server Components (RSC) remove client-side data-fetching waterfalls
- Routing — file-system routing eliminates manual
react-routerconfiguration- Full-stack — API routes and Server Actions mean you can write backend logic in the same codebase without a separate Express server
- Image optimization — automatic WebP conversion, lazy loading, and layout shift prevention via
next/imageAnswer pattern for this question: Start with "it's a production framework built on React," name the rendering models (SSR, SSG, ISR, RSC), then name a concrete trade-off you've made in real work (e.g., "we chose Next.js for our marketing site because organic search was a growth channel and a CSR SPA would have hurt rankings").
2. Explain the difference between Pages Router and App Router
Next.js has two routing paradigms that coexist in the same project.
Pages Router (stable since v1, still fully supported):
- Files in
/pagesmap to routes - Data fetching via
getServerSideProps,getStaticProps,getStaticPaths - Components are traditional React client components
- API routes in
/pages/api
App Router (stable since v13.4):
- Files in
/appmap to routes usingpage.tsxconvention - Components are React Server Components by default — they run on the server, never ship to the browser
- Data fetching is plain
async/awaitinside components orfetch()with extended caching semantics - Layouts via
layout.tsx, loading states vialoading.tsx, error boundaries viaerror.tsx - Server Actions for mutations
tsx// App Router — Server Component (no "use client" directive) // This component runs only on the server. Zero JS shipped for this component. async function UserProfile({ id }: { id: string }) { // Direct database/API call — no useEffect, no loading state on client const user = await db.users.findUnique({ where: { id } }); return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); }tsx// Pages Router equivalent // getServerSideProps runs on the server, result passed as props export async function getServerSideProps({ params }) { const user = await db.users.findUnique({ where: { id: params.id } }); return { props: { user } }; } export default function UserProfile({ user }) { return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); }Interview tip: Interviewers ask this to see whether you understand RSC. The key insight is that in the App Router, the default is server — you opt into the client with
"use client", not the other way around. This inverts the mental model from Pages Router.3. What are React Server Components (RSC) and how do they work in Next.js?
React Server Components are components that execute exclusively on the server. They can access databases, file systems, and secrets directly. They never hydrate on the client — meaning no JavaScript bundle for those components reaches the browser.
Key properties:
- Cannot use hooks (
useState,useEffect,useContext) - Cannot use browser APIs
- Cannot attach event listeners
- Can be
async—awaitdatabase calls directly - Can import server-only modules (e.g.,
bcrypt,fs) without bundle bloat
tsx// app/dashboard/page.tsx — Server Component import { db } from "@/lib/db"; import { cookies } from "next/headers"; // This runs on the server. The DB connection string never reaches the client. export default async function DashboardPage() { const session = cookies().get("session")?.value; const user = await db.user.findUnique({ where: { sessionToken: session }, include: { applications: { take: 10, orderBy: { createdAt: "desc" } } }, }); if (!user) redirect("/login"); return ( <main> <h1>Welcome, {user.name}</h1> {/* Pass serializable props down to a Client Component for interactivity */} <ApplicationList initialData={user.applications} /> </main> ); }tsx// components/ApplicationList.tsx — Client Component "use client"; import { useState } from "react"; export function ApplicationList({ initialData }) { // useState is fine here — this is a Client Component const [applications, setApplications] = useState(initialData); return ( <ul> {applications.map((app) => ( <li key={app.id}>{app.company}</li> ))} </ul> ); }The component tree rule: Client Components can import other Client Components. Server Components can import both. But a Client Component cannot import a Server Component directly — you can only pass Server Components as
childrenprops into Client Components.tsx// ✅ Valid: Server Component wraps Client Component, passing RSC as children // app/layout.tsx import { Modal } from "@/components/Modal"; // Client Component import { Sidebar } from "@/components/Sidebar"; // Server Component export default function Layout({ children }) { return ( <Modal> {/* Sidebar is a Server Component passed as children to a Client Component */} <Sidebar /> {children} </Modal> ); }4. What is the difference between SSR, SSG, ISR, and CSR in Next.js?
This is one of the most common Next.js interview questions. Interviewers want to see you understand the rendering spectrum and know when to apply each.
| Rendering | When HTML is generated | Data freshness | Use case |
|-----------|----------------------|----------------|----------|
| SSG (Static Site Generation) | Build time | Stale until next build | Blog posts, marketing pages, docs |
| ISR (Incremental Static Regeneration) | Build time + background regeneration | Up to N seconds stale | Product pages, news, dashboards with tolerable lag |
| SSR (Server-Side Rendering) | Each request | Always fresh | Personalized pages, auth-gated content, real-time data |
| CSR (Client-Side Rendering) | In the browser after hydration | Fetched client-side | User dashboards, search results that don't need SEO |
Pages Router implementation:
tsx// SSG — runs once at build time export async function getStaticProps() { const posts = await fetchBlogPosts(); return { props: { posts }, revalidate: 60, // ISR: regenerate if request comes after 60 seconds }; } // SSR — runs on every request export async function getServerSideProps(context) { const { req } = context; const session = getSession(req); const data = await fetchPersonalizedData(session.userId); return { props: { data } }; }App Router implementation:
tsx// SSG — no dynamic data, Next.js statically generates at build time export default async function BlogPost({ params }) { const post = await getPost(params.slug); // cached by default return <Article post={post} />; } // ISR — revalidate after 60 seconds async function getData() { const res = await fetch("https://api.example.com/products", { next: { revalidate: 60 }, }); return res.json(); } // SSR — no caching, fresh on every request async function getData() { const res = await fetch("https://api.example.com/user", { cache: "no-store", }); return res.json(); }Interview answer pattern: "It depends on the data's freshness requirements and SEO needs. Static with ISR is my default for anything that can tolerate a short lag. SSR when the page must be personalized per request. CSR when SEO doesn't matter and data changes per user interaction."
5. How does file-based routing work in the App Router?
The App Router uses a folder-based routing system where each folder in
app/represents a route segment. The actual UI is in special files within those folders.Reserved filenames:
| File | Purpose |
|------|---------|
|
page.tsx| The UI for that route — makes the segment publicly accessible ||
layout.tsx| Shared UI that wraps child routes, persists across navigation ||
loading.tsx| Automatic loading skeleton using React Suspense ||
error.tsx| Error boundary for the segment and its children ||
not-found.tsx| Custom 404 page ||
route.ts| API endpoint (replacespages/api) ||
template.tsx| Like layout but remounts on navigation (for animations) ||
middleware.ts| Runs before every request (root-level only) |app/ ├── layout.tsx → root layout (wraps everything) ├── page.tsx → / ├── blog/ │ ├── layout.tsx → wraps /blog and /blog/[slug] │ ├── page.tsx → /blog │ └── [slug]/ │ ├── page.tsx → /blog/some-post │ └── loading.tsx → automatic Suspense while fetching ├── (marketing)/ → route group — no URL segment │ ├── about/page.tsx → /about │ └── pricing/page.tsx → /pricing └── dashboard/ ├── @sidebar/ → parallel route — rendered simultaneously │ └── page.tsx └── page.tsx → /dashboardDynamic segments:
tsx// app/jobs/[id]/page.tsx export default function JobPage({ params }: { params: { id: string } }) { return <div>Job ID: {params.id}</div>; } // app/blog/[...slug]/page.tsx — catch-all: matches /blog/a/b/c export default function BlogPage({ params }: { params: { slug: string[] } }) { return <div>{params.slug.join("/")}</div>; }Data Fetching
6. How does data fetching work in the App Router?
The App Router treats
fetch()as a first-class primitive and extends its caching semantics. Server Components are async by default, so you fetch data directly in the component body.tsx// Cached indefinitely (default) — equivalent to SSG async function getProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`); return res.json(); } // No cache — runs on every request (equivalent to SSR) async function getLivePrice(id: string) { const res = await fetch(`https://api.example.com/prices/${id}`, { cache: "no-store", }); return res.json(); } // Time-based revalidation (equivalent to ISR) async function getPopularPosts() { const res = await fetch("https://api.example.com/posts/popular", { next: { revalidate: 300 }, // 5 minutes }); return res.json(); } // Tag-based revalidation async function getPosts() { const res = await fetch("https://api.example.com/posts", { next: { tags: ["posts"] }, }); return res.json(); } // On-demand revalidation in a Server Action or Route Handler import { revalidateTag } from "next/cache"; export async function POST() { revalidateTag("posts"); // Invalidate all fetches tagged "posts" return Response.json({ revalidated: true }); }Request deduplication: Next.js automatically deduplicates identical
fetch()calls within a single render pass — if 5 components fetch the same URL, only 1 HTTP request is made.Non-fetch data sources (ORMs, databases, SDKs):
tsximport { cache } from "react"; // Wrap in React's cache() for deduplication when not using fetch() export const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } }); });7. What are Server Actions and how do you use them?
Server Actions are async functions that run on the server but can be called directly from Client Components — including form submissions. They replace the need for manual API endpoints for mutations.
tsx// app/actions.ts "use server"; // Marks all exports as Server Actions import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; export async function createApplication(formData: FormData) { const company = formData.get("company") as string; const role = formData.get("role") as string; // Runs on server — direct DB access, no fetch needed await db.application.create({ data: { company, role, userId: getAuthenticatedUserId() }, }); // Revalidate the page so fresh data is shown revalidatePath("/dashboard"); }tsx// app/apply/page.tsx — using Server Action in a form import { createApplication } from "@/app/actions"; export default function ApplyPage() { return ( // action prop accepts a Server Action directly <form action={createApplication}> <input name="company" type="text" required /> <input name="role" type="text" required /> <button type="submit">Apply</button> </form> ); }tsx// Client Component calling a Server Action programmatically "use client"; import { createApplication } from "@/app/actions"; import { useTransition } from "react"; export function ApplyButton({ company, role }) { const [isPending, startTransition] = useTransition(); return ( <button onClick={() => { startTransition(async () => { await createApplication({ company, role }); }); }} disabled={isPending} > {isPending ? "Applying..." : "Apply"} </button> ); }Security note: Server Actions are automatically secured — Next.js uses a secret token to ensure only your app can invoke them. Still, always validate input on the server. Don't trust client-supplied data.
8. What is `getStaticPaths` and when do you need it?
getStaticPaths(Pages Router) tells Next.js which dynamic routes to pre-render at build time. You need it whenever you have a dynamic route ([slug]) combined withgetStaticProps.tsx// pages/blog/[slug].tsx export async function getStaticPaths() { const posts = await fetchAllPosts(); return { // Pre-build these specific paths paths: posts.map((post) => ({ params: { slug: post.slug } })), // fallback: false → 404 for paths not in the list // fallback: true → show loading state, generate on first request // fallback: 'blocking' → wait for generation, no loading state fallback: "blocking", }; } export async function getStaticProps({ params }) { const post = await fetchPost(params.slug); if (!post) return { notFound: true }; return { props: { post }, revalidate: 3600, // ISR: regenerate hourly }; }App Router equivalent:
tsx// app/blog/[slug]/page.tsx // generateStaticParams replaces getStaticPaths export async function generateStaticParams() { const posts = await fetchAllPosts(); return posts.map((post) => ({ slug: post.slug })); } export default async function BlogPost({ params }) { const post = await fetchPost(params.slug); return <Article post={post} />; }Routing & Navigation
9. How does navigation work in Next.js and what is the difference between `<Link>`, `useRouter`, and `redirect()`?
tsx// <Link> — declarative, prefetches on hover/viewport, preferred for UI import Link from "next/link"; export function Nav() { return ( <nav> <Link href="/dashboard">Dashboard</Link> {/* Prefetch is true by default in production */} <Link href="/blog" prefetch={false}>Blog</Link> {/* Replace instead of push to history */} <Link href="/onboarding" replace>Start</Link> </nav> ); }tsx// useRouter — programmatic navigation in Client Components "use client"; import { useRouter } from "next/navigation"; // App Router // import { useRouter } from "next/router"; // Pages Router export function SearchForm() { const router = useRouter(); function handleSubmit(e) { e.preventDefault(); const query = new FormData(e.target).get("q"); router.push(`/search?q=${query}`); // router.replace, router.back(), router.refresh() also available } return <form onSubmit={handleSubmit}>...</form>; }tsx// redirect() — Server-side redirect from Server Components or Server Actions import { redirect } from "next/navigation"; export default async function ProtectedPage() { const session = await getSession(); if (!session) { redirect("/login"); // Throws internally — no return needed after } return <Dashboard />; }Key differences:
is for rendered links in JSX — it handles prefetching automaticallyuseRouteris for programmatic navigation after an event (form submit, button click) — Client Components onlyredirect()is for server-side conditional redirects — runs before the page renders
10. What is Middleware in Next.js and what can you use it for?
Middleware runs before a request is completed — it can rewrite URLs, redirect, modify headers, or short-circuit a request entirely. It runs at the Edge (Vercel's network), making it extremely fast.
ts// middleware.ts (root of project) import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { const pathname = request.nextUrl.pathname; // Auth check — redirect unauthenticated users if (pathname.startsWith("/dashboard")) { const token = request.cookies.get("session")?.value; if (!token) { return NextResponse.redirect(new URL("/login", request.url)); } } // Geolocation-based routing const country = request.geo?.country || "US"; if (pathname === "/" && country === "AR") { return NextResponse.rewrite(new URL("/es", request.url)); } // A/B test via cookie if (pathname === "/landing") { const bucket = request.cookies.get("ab-bucket")?.value || "a"; return NextResponse.rewrite(new URL(`/landing-${bucket}`, request.url)); } return NextResponse.next(); } // Only run middleware on these paths (avoids running on static files) export const config = { matcher: ["/dashboard/:path*", "/", "/landing"], };What Middleware can and cannot do:
| Can do | Cannot do |
|--------|-----------|
| Read/set cookies | Access the file system |
| Rewrite/redirect URLs | Use Node.js APIs (fs, crypto, etc.) |
| Modify request/response headers | Make long-running database queries |
| Read geolocation, IP, user agent | Import large Node.js packages |
Middleware runs on the Edge Runtime — a lightweight JS environment. For heavy logic, use API routes or Server Components instead.
11. How do parallel routes and intercepting routes work?
These are advanced App Router features for complex UI patterns.
Parallel Routes — render multiple pages simultaneously in the same layout:
app/dashboard/ ├── layout.tsx → renders @analytics and @team simultaneously ├── page.tsx → /dashboard main content ├── @analytics/ │ └── page.tsx → rendered in the "analytics" slot └── @team/ └── page.tsx → rendered in the "team" slottsx// app/dashboard/layout.tsx export default function DashboardLayout({ children, analytics, // named slot matching @analytics folder team, // named slot matching @team folder }: { children: React.ReactNode; analytics: React.ReactNode; team: React.ReactNode; }) { return ( <div className="grid grid-cols-3"> <main>{children}</main> <aside>{analytics}</aside> <aside>{team}</aside> </div> ); }Intercepting Routes — intercept a route to show it in the current context (e.g., photo modal without leaving the feed):
app/ ├── photos/[id]/page.tsx → full photo page at /photos/123 └── (.)photos/[id]/page.tsx → intercepted — shows modal over feedThe
(.)prefix intercepts same-level routes.(..)intercepts one level up,(...)intercepts from root.Performance & Optimization
12. How does `next/image` work and why should you use it?
next/imageautomatically handles:- Format conversion — serves WebP/AVIF to supporting browsers
- Responsive sizing — generates multiple sizes, browser picks correct one
- Lazy loading — loads below-fold images only when needed
- Layout shift prevention — requires
width/heightorfillto reserve space (CLS = 0) - CDN delivery — serves from Next.js's built-in image CDN (or your own)
tsximport Image from "next/image"; // Fixed size image <Image src="/hero.png" alt="Hero image" width={800} height={600} priority // LCP image — skip lazy loading, preload instead /> // Fill container (needs position:relative parent) <div className="relative h-96"> <Image src={post.coverImage} alt={post.title} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" /> </div> // Remote image — must whitelist domain in next.config.mjs // next.config.mjs export default { images: { remotePatterns: [ { protocol: "https", hostname: "cdn.example.com", pathname: "/uploads/**", }, ], }, };Common mistake: Forgetting
priorityon the LCP (Largest Contentful Paint) image. The hero image is usually LCP — withoutpriority, it's lazy-loaded, hurting your Core Web Vitals score.13. What is `next/font` and how does it eliminate layout shift from fonts?
Custom fonts cause layout shift because the browser first renders with a fallback font, then swaps to the loaded font — changing element sizes.
next/fontsolves this by:- 1Downloading fonts at build time and self-hosting them (zero external requests)
- 2Automatically generating CSS
size-adjust,ascent-override, anddescent-overrideproperties so the fallback font matches the target font's metrics exactly
tsx// app/layout.tsx import { Inter, Roboto_Mono } from "next/font/google"; import localFont from "next/font/local"; const inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter", // CSS variable for Tailwind }); const mono = Roboto_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-mono", }); // Local font const brand = localFont({ src: [ { path: "./fonts/BrandFont-Regular.woff2", weight: "400" }, { path: "./fonts/BrandFont-Bold.woff2", weight: "700" }, ], variable: "--font-brand", }); export default function RootLayout({ children }) { return ( <html lang="en" className={`${inter.variable} ${mono.variable}`}> <body className={inter.className}>{children}</body> </html> ); }ts// tailwind.config.ts export default { theme: { extend: { fontFamily: { sans: ["var(--font-inter)"], mono: ["var(--font-mono)"], }, }, }, };14. How does code splitting work in Next.js?
Next.js automatically splits code at the route level — each page gets its own JavaScript bundle. Only the code for the current page is loaded.
Dynamic imports for component-level splitting:
tsximport dynamic from "next/dynamic"; // Split off a heavy component (e.g., a rich text editor) const RichEditor = dynamic(() => import("@/components/RichEditor"), { loading: () => <div className="h-40 animate-pulse bg-gray-200 rounded" />, ssr: false, // Editor uses browser APIs — skip SSR entirely }); // Split off a component only needed conditionally const PricingModal = dynamic(() => import("@/components/PricingModal")); export function Dashboard({ isPremium }) { return ( <div> <RichEditor /> {!isPremium && <PricingModal />} </div> ); }When to use
ssr: false:- Components that use
window,document,localStorage - Components that rely on browser-only libraries (e.g., Leaflet maps, Chart.js canvas)
- Components that differ significantly between server and client (preventing hydration mismatches)
15. How do you optimize Core Web Vitals in a Next.js app?
Core Web Vitals are Google's user-experience metrics: LCP (loading), INP/FID (interactivity), and CLS (visual stability).
LCP (Largest Contentful Paint) — target: under 2.5s
tsx// 1. Add priority to the LCP image <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority /> // 2. Preload critical fonts (next/font handles this automatically) // 3. Reduce server response time — use SSG/ISR instead of SSR for public pages // 4. Use next/image for automatic WebP serving and CDN cachingCLS (Cumulative Layout Shift) — target: under 0.1
tsx// Reserve space for images — always provide width/height <Image width={400} height={300} ... /> // Reserve space for ads/embeds with min-height <div style={{ minHeight: "250px" }}> <Advertisement /> </div> // Use next/font to prevent font swap layout shiftINP (Interaction to Next Paint) — target: under 200ms
tsx"use client"; import { useTransition } from "react"; export function FilterButton({ onFilter }) { const [isPending, startTransition] = useTransition(); return ( <button onClick={() => { // Mark state update as non-urgent — keeps UI responsive startTransition(() => onFilter("newest")); }} > {isPending ? "Loading..." : "Sort by Newest"} </button> ); }API Routes & Backend
16. How do Route Handlers work in the App Router?
Route Handlers replace
pages/apifiles. They live inapp/and use the WebRequest/ResponseAPIs.ts// app/api/jobs/route.ts import { NextRequest, NextResponse } from "next/server"; // GET /api/jobs?q=react export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const query = searchParams.get("q") ?? ""; const jobs = await db.job.findMany({ where: { title: { contains: query, mode: "insensitive" } }, take: 20, }); return NextResponse.json({ jobs }); } // POST /api/jobs export async function POST(request: NextRequest) { const body = await request.json(); // Validate input const parsed = jobSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: parsed.error }, { status: 400 }); } const job = await db.job.create({ data: parsed.data }); return NextResponse.json({ job }, { status: 201 }); }ts// app/api/jobs/[id]/route.ts — dynamic route handlers export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { const job = await db.job.findUnique({ where: { id: params.id } }); if (!job) return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json(job); } export async function DELETE( request: NextRequest, { params }: { params: { id: string } } ) { await db.job.delete({ where: { id: params.id } }); return new Response(null, { status: 204 }); }Caching Route Handlers:
ts// Cached (static) — result is cached at build time export const dynamic = "force-static"; // Never cached export const dynamic = "force-dynamic";17. How do you handle authentication in Next.js?
The most common pattern is session-based auth with JWTs or server-side sessions, validated in Middleware.
ts// lib/auth.ts — using jose for JWT verification at Edge import { jwtVerify } from "jose"; const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET); export async function verifyToken(token: string) { try { const { payload } = await jwtVerify(token, JWT_SECRET); return payload as { userId: string; email: string }; } catch { return null; } }ts// middleware.ts — protect routes at the Edge import { NextResponse } from "next/server"; import { verifyToken } from "@/lib/auth"; export async function middleware(request) { const token = request.cookies.get("session")?.value; const payload = token ? await verifyToken(token) : null; if (!payload && request.nextUrl.pathname.startsWith("/dashboard")) { const loginUrl = new URL("/login", request.url); loginUrl.searchParams.set("next", request.nextUrl.pathname); return NextResponse.redirect(loginUrl); } // Forward user identity to Server Components via headers const response = NextResponse.next(); if (payload) { response.headers.set("x-user-id", payload.userId); } return response; }tsx// Server Component reading the user from headers import { headers } from "next/headers"; export default async function DashboardPage() { const userId = headers().get("x-user-id"); const user = await db.user.findUnique({ where: { id: userId } }); return <Dashboard user={user} />; }NextAuth.js / Auth.js is the community standard for production auth:
ts// app/api/auth/[...nextauth]/route.ts import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; const handler = NextAuth({ adapter: DrizzleAdapter(db), providers: [ GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET, }), ], callbacks: { session({ session, user }) { session.user.id = user.id; return session; }, }, }); export { handler as GET, handler as POST };18. How do you handle environment variables in Next.js?
bash# .env.local (never committed) DATABASE_URL=postgresql://... JWT_SECRET=supersecret # Prefix with NEXT_PUBLIC_ to expose to the browser NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... NEXT_PUBLIC_APP_URL=https://myapp.comts// Server-only (safe) const db = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL } }, }); // Client-safe (bundled into JS — never put secrets here) const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);Type-safe environment variables with
@t3-oss/env-nextjs:ts// env.mjs import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_APP_URL: z.string().url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, JWT_SECRET: process.env.JWT_SECRET, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, }); // Build fails immediately if env vars are missing or wrong shapeAdvanced Topics
19. What is the difference between `useRouter` from `next/navigation` vs `next/router`?
ts// next/router — Pages Router ONLY import { useRouter } from "next/router"; // next/navigation — App Router ONLY import { useRouter } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";App Router navigation hooks:
tsx"use client"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; export function SearchComponent() { const router = useRouter(); const pathname = usePathname(); // e.g., "/dashboard/jobs" const searchParams = useSearchParams(); // URLSearchParams instance const query = searchParams.get("q") ?? ""; function updateSearch(newQuery: string) { const params = new URLSearchParams(searchParams); params.set("q", newQuery); // push vs. replace — replace avoids polluting browser history for filters router.replace(`${pathname}?${params.toString()}`); } return <input value={query} onChange={(e) => updateSearch(e.target.value)} />; }Common mistake: Using
useSearchParamsin a Server Component. It only works in Client Components. For Server Components, read search params frompage.tsxprops instead:tsx// Server Component — searchParams come as props export default function SearchPage({ searchParams, }: { searchParams: { q?: string }; }) { const query = searchParams.q ?? ""; return <Results query={query} />; }20. How do you implement Streaming in Next.js?
Streaming sends HTML to the browser in chunks as it becomes ready — users see meaningful content before the entire page is generated.
tsx// app/dashboard/page.tsx import { Suspense } from "react"; // This page streams: the shell renders immediately, // each Suspense boundary streams in as its data resolves export default function DashboardPage() { return ( <main> {/* Renders immediately — no data needed */} <DashboardHeader /> {/* Streams in with its own loading skeleton */} <Suspense fallback={<StatsSkeleton />}> <Stats /> {/* Async Server Component */} </Suspense> {/* Streams in independently — doesn't block Stats */} <Suspense fallback={<JobListSkeleton />}> <RecentJobs /> {/* Another async Server Component */} </Suspense> </main> ); } // Stats fetches its own data — runs in parallel with RecentJobs async function Stats() { const stats = await getStats(); // 200ms return <StatsDisplay stats={stats} />; } async function RecentJobs() { const jobs = await getRecentJobs(); // 800ms — doesn't block Stats return <JobList jobs={jobs} />; }loading.tsx— automatic Suspense wrapper:tsx// app/dashboard/loading.tsx // Next.js automatically wraps the page in <Suspense fallback={<Loading />}> export default function Loading() { return <DashboardSkeleton />; }21. How do you handle errors in Next.js App Router?
tsx// app/dashboard/error.tsx — Error boundary for /dashboard and its children "use client"; // Error components must be Client Components import { useEffect } from "react"; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; // Retry function provided by Next.js }) { useEffect(() => { // Log to error monitoring service console.error(error); Sentry.captureException(error); }, [error]); return ( <div> <h2>Something went wrong</h2> <button onClick={reset}>Try again</button> </div> ); }tsx// app/global-error.tsx — catches errors in root layout "use client"; export default function GlobalError({ error, reset }) { return ( <html> <body> <h2>Application error</h2> <button onClick={reset}>Reload</button> </body> </html> ); }Not Found pages:
tsx// app/not-found.tsx export default function NotFound() { return ( <div> <h1>404 — Page not found</h1> <Link href="/">Go home</Link> </div> ); } // In a Server Component — trigger not-found import { notFound } from "next/navigation"; async function JobPage({ params }) { const job = await getJob(params.id); if (!job) notFound(); // Renders not-found.tsx return <JobDetail job={job} />; }22. How do you implement ISR (Incremental Static Regeneration) with on-demand revalidation?
ts// app/api/revalidate/route.ts import { revalidatePath, revalidateTag } from "next/cache"; import { NextRequest } from "next/server"; export async function POST(request: NextRequest) { // Validate webhook secret const secret = request.headers.get("x-webhook-secret"); if (secret !== process.env.REVALIDATION_SECRET) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const body = await request.json(); if (body.type === "post.updated") { // Revalidate specific path revalidatePath(`/blog/${body.slug}`); revalidatePath("/blog"); // Revalidate listing page too } if (body.type === "products.updated") { // Revalidate all pages tagged "products" revalidateTag("products"); } return Response.json({ revalidated: true, timestamp: Date.now() }); }tsx// Tag your fetches for targeted invalidation async function getProducts() { const res = await fetch("https://api.example.com/products", { next: { tags: ["products"] }, }); return res.json(); } // For non-fetch data sources import { unstable_cache } from "next/cache"; const getCachedProducts = unstable_cache( async () => db.product.findMany(), ["products-list"], // cache key { tags: ["products"], revalidate: 3600 } );23. What is the `<Suspense>` boundary and how does it interact with Next.js?
Suspenseis a React primitive that Next.js uses for three things: streaming HTML, lazy-loading Client Components, and handling async data fetching with loading states.tsximport { Suspense } from "react"; import dynamic from "next/dynamic"; // Dynamic import creates an automatic Suspense point const HeavyChart = dynamic(() => import("@/components/Chart"), { loading: () => <ChartSkeleton />, ssr: false, }); // Nested Suspense — granular loading control export default function AnalyticsPage() { return ( <div> {/* This loads fast — no Suspense needed */} <PageHeader title="Analytics" /> {/* Outer Suspense shows skeleton while inner data loads */} <Suspense fallback={<SectionSkeleton />}> <MetricsSection> {/* Inner Suspense for a slower data dependency */} <Suspense fallback={<ChartSkeleton />}> <RevenueChart /> </Suspense> {/* This renders independently of RevenueChart */} <ConversionRate /> </MetricsSection> </Suspense> </div> ); }The
use()hook (React 19 / Next.js 15+):tsx"use client"; import { use, Suspense } from "react"; // Pass a Promise from Server Component to Client Component export function ClientComponent({ dataPromise }: { dataPromise: Promise<Data> }) { // use() unwraps the promise — triggers Suspense above while pending const data = use(dataPromise); return <div>{data.title}</div>; } // Server Component export default function Page() { const dataPromise = fetchData(); // Don't await — pass the promise return ( <Suspense fallback={<Skeleton />}> <ClientComponent dataPromise={dataPromise} /> </Suspense> ); }24. How do you implement optimistic updates with Server Actions?
tsx"use client"; import { useOptimistic, useTransition } from "react"; import { toggleLike } from "@/app/actions"; interface Post { id: string; likes: number; likedByUser: boolean; } export function LikeButton({ post }: { post: Post }) { const [isPending, startTransition] = useTransition(); // useOptimistic — show predicted UI state before server confirms const [optimisticPost, addOptimisticLike] = useOptimistic( post, (currentPost, liked: boolean) => ({ ...currentPost, likedByUser: liked, likes: liked ? currentPost.likes + 1 : currentPost.likes - 1, }) ); function handleLike() { startTransition(async () => { // Update UI immediately — don't wait for server addOptimisticLike(!optimisticPost.likedByUser); // Then sync with server await toggleLike(post.id); // If toggleLike throws, React automatically reverts optimistic state }); } return ( <button onClick={handleLike} disabled={isPending}> {optimisticPost.likedByUser ? "Unlike" : "Like"} ({optimisticPost.likes}) </button> ); }25. How does the Next.js build process work?
bashnext buildThe build process:
- 1Type checking — runs TypeScript compiler
- 2Linting — runs ESLint
- 3Compilation — compiles all pages with SWC (Rust-based, 17x faster than Babel)
- 4Route analysis — categorizes each route as Static, Dynamic, or ISR
- 5Pre-rendering — generates HTML for all static routes
- 6Bundle analysis — creates optimized JS chunks with tree-shaking
Build output:
Route (app) Size First Load JS ┌ ○ / 5.2 kB 87.1 kB ├ ○ /about 1.2 kB 83.1 kB ├ ● /blog/[slug] 3.5 kB 85.4 kB (ISR: 3600s) ├ λ /dashboard 8.1 kB 90.0 kB (Dynamic: SSR) └ ○ /blog 4.1 kB 86.0 kB ○ Static ● ISR λ DynamicAnalyzing bundle size:
bash# Install npm install @next/bundle-analyzer # next.config.mjs import bundleAnalyzer from "@next/bundle-analyzer"; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); export default withBundleAnalyzer({}); # Run ANALYZE=true npm run build26. What is `next.config.mjs` and what are the most important options?
mjs// next.config.mjs /** @type {import('next').NextConfig} */ const nextConfig = { // Enable React's strict mode — double-invokes effects in dev to catch bugs reactStrictMode: true, // Redirect rules async redirects() { return [ { source: "/old-blog/:slug", destination: "/blog/:slug", permanent: true, // 308 vs 307 }, ]; }, // Rewrite rules (proxy without URL change) async rewrites() { return [ { source: "/api/v1/:path*", destination: "https://internal-api.com/:path*", }, ]; }, // Custom response headers async headers() { return [ { source: "/(.*)", headers: [ { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, ], }, ]; }, // Experimental features experimental: { ppr: true, // Partial Pre-Rendering }, // Webpack customization webpack(config) { config.plugins.push(new MyPlugin()); return config; }, }; export default nextConfig;27. What is Partial Pre-Rendering (PPR)?
PPR is an experimental Next.js 14+ rendering model that combines static and dynamic content in a single page — without forcing the whole page to be dynamic.
tsx// app/product/[id]/page.tsx import { Suspense } from "react"; import { experimental_ppr } from "next"; // Enable PPR for this route export const experimental_ppr = true; export default async function ProductPage({ params }) { return ( <div> {/* Static shell — rendered at build time, served instantly from CDN. Next.js pre-renders everything outside Suspense boundaries statically. */} <ProductLayout> <StaticProductInfo params={params} /> {/* Dynamic hole — filled with streaming content per request. Suspense boundary = the "hole" in the static shell. */} <Suspense fallback={<PriceSkeleton />}> <DynamicPrice productId={params.id} /> {/* Personalized, no-cache */} </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <UserReviews productId={params.id} /> {/* Fresh per request */} </Suspense> </ProductLayout> </div> ); }Why this matters: Previously you had to choose — static (fast but stale) or dynamic (fresh but slower). PPR gives you the CDN performance of static pages with the freshness of SSR, in the same response.
28. How do you implement TypeScript with Next.js effectively?
tsx// Strongly typed page props interface PageProps { params: { id: string }; searchParams: { tab?: "overview" | "reviews"; page?: string }; } export default async function ProductPage({ params, searchParams }: PageProps) { const tab = searchParams.tab ?? "overview"; const page = Number(searchParams.page ?? "1"); // ... } // generateMetadata — typed return import type { Metadata } from "next"; export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const product = await getProduct(params.id); return { title: product.name, description: product.description, openGraph: { images: [{ url: product.imageUrl }], }, }; }ts// Typed Server Actions "use server"; import { z } from "zod"; const CreateJobSchema = z.object({ title: z.string().min(3).max(100), company: z.string().min(2), salary: z.number().positive().optional(), }); type ActionState = { success: true; id: string } | { success: false; errors: string[] }; export async function createJob( prevState: ActionState, formData: FormData ): Promise<ActionState> { const result = CreateJobSchema.safeParse({ title: formData.get("title"), company: formData.get("company"), salary: formData.get("salary") ? Number(formData.get("salary")) : undefined, }); if (!result.success) { return { success: false, errors: result.error.issues.map((i) => i.message), }; } const job = await db.job.create({ data: result.data }); return { success: true, id: job.id }; }tsx// Using typed Server Action with useFormState "use client"; import { useFormState } from "react-dom"; import { createJob } from "@/app/actions"; export function CreateJobForm() { const [state, action] = useFormState(createJob, { success: true, id: "" }); return ( <form action={action}> {!state.success && ( <ul>{state.errors.map((e) => <li key={e}>{e}</li>)}</ul> )} <input name="title" /> <input name="company" /> <button type="submit">Create</button> </form> ); }29. How do you handle metadata and SEO in Next.js?
tsx// app/layout.tsx — site-wide defaults import type { Metadata } from "next"; export const metadata: Metadata = { // Template: page titles will render as "Page Title | My Site" title: { default: "My Site", template: "%s | My Site", }, description: "Default site description", metadataBase: new URL("https://mysite.com"), // Open Graph openGraph: { type: "website", siteName: "My Site", }, // Twitter Cards twitter: { card: "summary_large_image", creator: "@myhandle", }, // Robots robots: { index: true, follow: true, }, };tsx// app/blog/[slug]/page.tsx — dynamic metadata per page export async function generateMetadata( { params }: { params: { slug: string } } ): Promise<Metadata> { const post = await getPost(params.slug); return { title: post.title, // Renders as "Post Title | My Site" due to template description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, type: "article", publishedTime: post.publishedAt.toISOString(), authors: [post.author.name], images: [ { url: post.ogImage ?? "/og-default.png", width: 1200, height: 630, alt: post.title, }, ], }, // Canonical URL alternates: { canonical: `/blog/${params.slug}`, }, }; }Structured data (JSON-LD) for rich results:
tsxexport default async function BlogPost({ params }) { const post = await getPost(params.slug); const jsonLd = { "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, description: post.excerpt, datePublished: post.publishedAt, author: { "@type": "Person", name: post.author.name }, image: post.ogImage, }; return ( <> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <article>{/* ... */}</article> </> ); }30. How do you generate a sitemap in Next.js?
ts// app/sitemap.ts import type { MetadataRoute } from "next"; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await db.post.findMany({ select: { slug: true, updatedAt: true }, }); const postUrls = posts.map((post) => ({ url: `https://mysite.com/blog/${post.slug}`, lastModified: post.updatedAt, changeFrequency: "weekly" as const, priority: 0.7, })); return [ { url: "https://mysite.com", lastModified: new Date(), changeFrequency: "daily", priority: 1, }, { url: "https://mysite.com/blog", lastModified: new Date(), changeFrequency: "daily", priority: 0.8, }, ...postUrls, ]; }ts// app/robots.ts import type { MetadataRoute } from "next"; export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/" }, { userAgent: "GPTBot", allow: "/" }, // Allow AI citation crawlers ], sitemap: "https://mysite.com/sitemap.xml", }; }Testing
31. How do you test Next.js applications?
Unit tests with Vitest:
ts// lib/utils.test.ts import { describe, it, expect } from "vitest"; import { formatSalary, slugify } from "./utils"; describe("formatSalary", () => { it("formats USD correctly", () => { expect(formatSalary(120000, "USD")).toBe("$120,000/yr"); }); it("handles missing salary", () => { expect(formatSalary(null)).toBe("Salary not disclosed"); }); });Testing Server Components with React Testing Library:
tsx// __tests__/JobCard.test.tsx import { render, screen } from "@testing-library/react"; import { JobCard } from "@/components/JobCard"; // Mock the database module vi.mock("@/lib/db", () => ({ db: { job: { findUnique: vi.fn() } }, })); describe("JobCard", () => { it("renders job title and company", () => { render( <JobCard job={{ id: "1", title: "Senior Engineer", company: "Acme Inc" }} /> ); expect(screen.getByText("Senior Engineer")).toBeInTheDocument(); expect(screen.getByText("Acme Inc")).toBeInTheDocument(); }); });E2E testing with Playwright:
ts// e2e/auth.spec.ts import { test, expect } from "@playwright/test"; test("unauthenticated user is redirected to login", async ({ page }) => { await page.goto("/dashboard"); await expect(page).toHaveURL("/login?next=/dashboard"); }); test("authenticated user sees their dashboard", async ({ page, context }) => { // Set session cookie directly await context.addCookies([ { name: "session", value: testSessionToken, domain: "localhost" }, ]); await page.goto("/dashboard"); await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible(); });32. How do you mock `next/navigation` in tests?
tsx// __tests__/SearchComponent.test.tsx import { render, screen, fireEvent } from "@testing-library/react"; import { SearchComponent } from "@/components/SearchComponent"; // Mock the entire next/navigation module const mockPush = vi.fn(); const mockReplace = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush, replace: mockReplace, back: vi.fn(), refresh: vi.fn(), }), usePathname: () => "/dashboard", useSearchParams: () => new URLSearchParams("q=react"), })); test("submitting search navigates to results", () => { render(<SearchComponent />); const input = screen.getByRole("searchbox"); fireEvent.change(input, { target: { value: "nextjs" } }); fireEvent.submit(input.closest("form")!); expect(mockReplace).toHaveBeenCalledWith("/dashboard?q=nextjs"); });Common Interview Scenarios
33. How would you implement infinite scroll in Next.js?
tsx// app/jobs/page.tsx — Server Component with initial data export default async function JobsPage() { const initialJobs = await getJobs({ page: 1, limit: 20 }); return <InfiniteJobList initialJobs={initialJobs} />; }tsx// components/InfiniteJobList.tsx — Client Component "use client"; import { useState, useEffect, useRef, useCallback } from "react"; export function InfiniteJobList({ initialJobs }) { const [jobs, setJobs] = useState(initialJobs); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const sentinelRef = useRef<HTMLDivElement>(null); const loadMore = useCallback(async () => { if (loading || !hasMore) return; setLoading(true); const nextPage = page + 1; const newJobs = await fetch(`/api/jobs?page=${nextPage}`).then((r) => r.json()); if (newJobs.length === 0) { setHasMore(false); } else { setJobs((prev) => [...prev, ...newJobs]); setPage(nextPage); } setLoading(false); }, [loading, hasMore, page]); // Intersection Observer — triggers loadMore when sentinel enters viewport useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) loadMore(); }, { rootMargin: "200px" } // Start loading 200px before sentinel is visible ); if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [loadMore]); return ( <div> {jobs.map((job) => <JobCard key={job.id} job={job} />)} <div ref={sentinelRef} /> {loading && <LoadingSpinner />} {!hasMore && <p>No more jobs</p>} </div> ); }34. How would you implement a multi-tenant architecture in Next.js?
ts// middleware.ts import { NextResponse } from "next/server"; export function middleware(request) { const hostname = request.headers.get("host") ?? ""; // app.mysite.com → /app // [tenant].mysite.com → /[tenant] // mysite.com → main site const isApp = hostname.startsWith("app."); const tenantMatch = hostname.match(/^([^.]+)\.mysite\.com$/); if (isApp) { return NextResponse.rewrite( new URL(`/app${request.nextUrl.pathname}`, request.url) ); } if (tenantMatch && tenantMatch[1] !== "www") { const tenant = tenantMatch[1]; // Forward tenant to the app via header const response = NextResponse.rewrite( new URL(`/tenant/${tenant}${request.nextUrl.pathname}`, request.url) ); response.headers.set("x-tenant", tenant); return response; } return NextResponse.next(); }tsx// app/tenant/[tenant]/page.tsx import { headers } from "next/headers"; export default async function TenantPage({ params }) { const tenant = params.tenant; const tenantConfig = await getTenantConfig(tenant); if (!tenantConfig) notFound(); return <TenantDashboard config={tenantConfig} />; }35. How do you handle form validation with Server Actions?
tsx// app/actions.ts "use server"; import { z } from "zod"; import { redirect } from "next/navigation"; const ContactSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Invalid email address"), message: z.string().min(10, "Message must be at least 10 characters"), }); export type FormState = { errors?: { name?: string[]; email?: string[]; message?: string[]; _form?: string[]; }; success?: boolean; }; export async function submitContact( prevState: FormState, formData: FormData ): Promise<FormState> { const validatedFields = ContactSchema.safeParse({ name: formData.get("name"), email: formData.get("email"), message: formData.get("message"), }); if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, }; } try { await sendEmail(validatedFields.data); return { success: true }; } catch (error) { return { errors: { _form: ["Failed to send message. Please try again."] }, }; } }tsx// components/ContactForm.tsx "use client"; import { useFormState, useFormStatus } from "react-dom"; import { submitContact, type FormState } from "@/app/actions"; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? "Sending..." : "Send Message"} </button> ); } export function ContactForm() { const [state, action] = useFormState(submitContact, {}); if (state.success) { return <p>Message sent successfully!</p>; } return ( <form action={action} noValidate> <div> <label htmlFor="name">Name</label> <input id="name" name="name" type="text" /> {state.errors?.name && ( <p role="alert" className="text-red-500"> {state.errors.name[0]} </p> )} </div> <div> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" /> {state.errors?.email && ( <p role="alert">{state.errors.email[0]}</p> )} </div> <div> <label htmlFor="message">Message</label> <textarea id="message" name="message" /> {state.errors?.message && ( <p role="alert">{state.errors.message[0]}</p> )} </div> {state.errors?._form && ( <p role="alert" className="text-red-500"> {state.errors._form[0]} </p> )} <SubmitButton /> </form> ); }36. How do you deploy a Next.js app and what are the deployment options?
Vercel (zero-config):
bashnpm install -g vercel vercel # Follow the prompts — automatic detection and deploymentDocker (self-hosted):
dockerfile# Dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --only=production FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Use standalone output for minimal image size COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["node", "server.js"]mjs// next.config.mjs — required for Docker standalone export default { output: "standalone", };Static export (no server needed):
mjs// next.config.mjs export default { output: "export", // All pages must be statically generateable — no SSR, no API routes };bashnext build # Outputs to /out directory # Deploy /out to any static host: S3, GitHub Pages, Cloudflare Pages37. What are the differences between `layout.tsx` and `template.tsx`?
tsx// layout.tsx — persists across navigation, does NOT remount // State is preserved when navigating between children export default function DashboardLayout({ children }) { // This component mounts once. useEffect runs once. // Navigating between /dashboard/jobs and /dashboard/settings // does NOT remount this layout. return ( <div> <Sidebar /> {/* Stays mounted — sidebar state persists */} <main>{children}</main> </div> ); }tsx// template.tsx — remounts on EVERY navigation // Use for: animations, useEffect on each navigation, per-route state reset export default function Template({ children }) { // useEffect runs on EVERY navigation to a child route // Entering animation will replay on each route change return ( <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} > {children} </motion.div> ); }Rule of thumb: Use
layout.tsxby default. Usetemplate.tsxwhen you need enter/exit animations or when you wantuseEffectto fire on every route change within the segment.38. How do you implement role-based access control (RBAC)?
ts// lib/rbac.ts type Role = "user" | "admin" | "moderator"; type Permission = "read:jobs" | "write:jobs" | "delete:jobs" | "manage:users"; const rolePermissions: Record<Role, Permission[]> = { user: ["read:jobs"], moderator: ["read:jobs", "write:jobs"], admin: ["read:jobs", "write:jobs", "delete:jobs", "manage:users"], }; export function hasPermission(role: Role, permission: Permission): boolean { return rolePermissions[role]?.includes(permission) ?? false; }ts// middleware.ts — coarse-grained RBAC at the Edge export async function middleware(request) { const token = request.cookies.get("session")?.value; const payload = token ? await verifyToken(token) : null; if (request.nextUrl.pathname.startsWith("/admin")) { if (!payload || payload.role !== "admin") { return NextResponse.redirect(new URL("/403", request.url)); } } return NextResponse.next(); }tsx// Fine-grained RBAC in Server Components async function AdminPanel() { const session = await getServerSession(); if (!hasPermission(session.user.role, "manage:users")) { return <p>You don't have permission to view this.</p>; } const users = await getAllUsers(); return <UserManagement users={users} />; }39. How does Next.js handle CSS and styling?
tsx// 1. CSS Modules — scoped by default // styles/Button.module.css .button { background: blue; color: white; } .large { font-size: 1.25rem; } // Button.tsx import styles from "./Button.module.css"; <button className={`${styles.button} ${isLarge ? styles.large : ""}`}> // With clsx for conditional classes import clsx from "clsx"; <button className={clsx(styles.button, { [styles.large]: isLarge })}>tsx// 2. Tailwind CSS — most popular in Next.js ecosystem <button className={clsx( "px-4 py-2 rounded-md font-medium transition-colors", variant === "primary" && "bg-blue-600 text-white hover:bg-blue-700", variant === "secondary" && "bg-gray-100 text-gray-800 hover:bg-gray-200", disabled && "opacity-50 cursor-not-allowed" )}> // 3. CSS-in-JS — only some work with App Router // styled-components requires 'use client' and a registry setup // Vanilla Extract works at build time — compatible with RSCGlobal styles:
tsx// app/layout.tsx import "@/styles/globals.css"; // Only import in root layout export default function RootLayout({ children }) { return <html><body>{children}</body></html>; }40. What are common performance pitfalls in Next.js and how do you avoid them?
Pitfall 1: Putting everything in Client Components
tsx// ❌ Unnecessary "use client" — loses RSC benefits "use client"; async function ProductList() { // This doesn't use any client features, yet ships JS to browser const products = await getProducts(); return <ul>{products.map(p => <li>{p.name}</li>)}</ul>; } // ✅ Server Component — zero client JS for this component async function ProductList() { const products = await getProducts(); return <ul>{products.map(p => <li>{p.name}</li>)}</ul>; }Pitfall 2: Not using
loading.tsxfor slow pages// ❌ User sees blank screen for 800ms // ✅ Add loading.tsx — instant skeleton app/dashboard/ ├── page.tsx → slow (800ms database query) └── loading.tsx → instant skeleton shown immediatelyPitfall 3: Waterfall data fetching
tsx// ❌ Sequential — total time = 200ms + 300ms + 400ms = 900ms async function Page() { const user = await getUser(); const posts = await getPosts(user.id); const comments = await getComments(posts[0].id); } // ✅ Parallel — total time = max(200ms, 300ms, 400ms) = 400ms async function Page() { const [user, posts, comments] = await Promise.all([ getUser(), getPosts(), getComments(), ]); }Pitfall 4: Not providing image dimensions
tsx// ❌ Causes CLS — browser doesn't know space to reserve <Image src="/photo.jpg" alt="Photo" /> // ✅ Dimensions reserved — CLS = 0 <Image src="/photo.jpg" alt="Photo" width={800} height={600} />Pitfall 5: Importing heavy libraries on the client
tsx// ❌ Ships 200KB moment.js to every user "use client"; import moment from "moment"; const formatted = moment(date).format("MMMM DD, YYYY"); // ✅ Format on server — zero client bundle cost async function PostDate({ date }: { date: Date }) { // Runs on server — Intl is free, no bundle const formatted = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric" }).format(date); return <time dateTime={date.toISOString()}>{formatted}</time>; }41. How do you implement a real-time feature in Next.js?
tsx// Route Handler for Server-Sent Events (SSE) // app/api/notifications/route.ts export async function GET(request: Request) { const stream = new ReadableStream({ start(controller) { const encoder = new TextEncoder(); // Subscribe to notifications for this user const unsubscribe = subscribeToNotifications(userId, (notification) => { const data = `data: ${JSON.stringify(notification)}\n\n`; controller.enqueue(encoder.encode(data)); }); // Cleanup when client disconnects request.signal.addEventListener("abort", () => { unsubscribe(); controller.close(); }); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, }); }tsx// Client Component consuming SSE "use client"; import { useEffect, useState } from "react"; export function NotificationFeed() { const [notifications, setNotifications] = useState([]); useEffect(() => { const eventSource = new EventSource("/api/notifications"); eventSource.onmessage = (event) => { const notification = JSON.parse(event.data); setNotifications((prev) => [notification, ...prev]); }; eventSource.onerror = () => eventSource.close(); return () => eventSource.close(); }, []); return ( <ul> {notifications.map((n) => ( <li key={n.id}>{n.message}</li> ))} </ul> ); }For WebSockets, use a dedicated service (Pusher, Ably, Socket.io) since Next.js serverless functions don't support persistent connections.
42. What is the `cache()` function in React and when do you use it in Next.js?
tsx// lib/data.ts import { cache } from "react"; // cache() memoizes the result for the duration of a single request. // Multiple calls with the same argument in the same render pass // only execute the function once. export const getUser = cache(async (id: string) => { console.log(`Fetching user ${id}...`); // Logs once even if called 5 times return db.user.findUnique({ where: { id } }); }); // Multiple Server Components can call getUser(userId) // — only one database query is made per requestWhen to use
cache():- When using ORMs, database clients, or any async function that is NOT
fetch()(becausefetch()is automatically deduplicated by Next.js) - When the same data is needed by multiple components in the same render tree
- When you want to preload data (call without awaiting before the component that needs it)
tsx// Preloading pattern async function UserProfilePage({ params }) { // Start fetching immediately — don't wait for UserHeader to render getUser(params.id); // Fire and forget — cache() stores the result return ( <div> <UserHeader userId={params.id} /> {/* Will use cached result */} <UserPosts userId={params.id} /> {/* Will use cached result */} </div> ); }43. How do you handle internationalization (i18n) in Next.js?
mjs// next.config.mjs — built-in i18n (Pages Router) export default { i18n: { locales: ["en", "es", "pt"], defaultLocale: "en", localeDetection: true, }, };App Router i18n (using
next-intl):app/ └── [locale]/ ├── layout.tsx └── page.tsxtsx// middleware.ts — redirect to correct locale import createMiddleware from "next-intl/middleware"; export default createMiddleware({ locales: ["en", "es", "pt"], defaultLocale: "en", localePrefix: "as-needed", // /about (EN), /es/about (ES) }); export const config = { matcher: ["/((?!api|_next|.*\\..*).*)", "/"], };tsx// app/[locale]/layout.tsx import { NextIntlClientProvider } from "next-intl"; import { getMessages } from "next-intl/server"; export default async function LocaleLayout({ children, params: { locale } }) { const messages = await getMessages(); return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> ); }tsx// Server Component translation import { useTranslations } from "next-intl"; export function HeroSection() { const t = useTranslations("Hero"); return ( <h1>{t("title")}</h1> // messages/en.json: { "Hero": { "title": "..." } } ); }44. What is `unstable_noStore` and when would you use it?
tsimport { unstable_noStore as noStore } from "next/cache"; async function getLiveData() { noStore(); // Explicitly opt out of all caching for this call // Equivalent to fetch(..., { cache: "no-store" }) but works // with non-fetch data sources like ORM calls const data = await db.liveMetrics.findFirst({ orderBy: { timestamp: "desc" }, }); return data; }Use
noStore()when:- You need data that is fresh on every request
- You're using an ORM or SDK (not
fetch) and can't passcache: "no-store" - You want to explicitly document "this component opts out of caching" without relying on
cache: "no-store"on afetchcall
45. How do you debug Next.js performance issues?
bash# 1. Analyze JavaScript bundle ANALYZE=true npm run build # Opens webpack-bundle-analyzer in browser — spot large dependencies # 2. Check what's being rendered where # App Router: add console.log to Server Components — logs appear in TERMINAL # App Router: add console.log to Client Components — logs appear in BROWSER console # 3. TypeScript strict mode catches client/server boundary violations at compile time # 4. Enable React DevTools Profiler (Client Components only) # 5. Speed Insights (Vercel) — real user Core Web Vitalstsx// Debug: check if a component is Server or Client // Server Components run during build/request — log appears in terminal // Client Components run in browser — log appears in DevTools async function MyComponent() { console.log("I am a Server Component"); // Terminal return <div />; } "use client"; function MyClientComponent() { console.log("I am a Client Component"); // Browser DevTools return <div />; }tsx// The "server-only" package — prevents server code from being imported in Client Components import "server-only"; // Throws build error if imported in a Client Component export async function getSecretData() { return db.secrets.findMany(); // This function cannot accidentally end up client-side }What Interviewers Are Really Testing
Beyond the individual questions, experienced Next.js interviewers are looking for three things:
1. Mental model clarity — Do you understand the rendering spectrum? Can you explain *why* you'd choose SSG over SSR for a given page, not just *what* they are? The "it depends" answer is correct, but you need to complete the sentence with the right criteria.
2. Boundary awareness — Do you know where the server ends and the client begins? Can you explain why
"use client"exists, why Server Components can't use hooks, and how to pass data across the boundary correctly? This is the hardest concept for developers new to the App Router.3. Trade-off reasoning — Next.js gives you many tools. The signal interviewers look for is whether you default to the most performant option (Server Component, SSG) and only reach for the heavier option (Client Component, SSR) when there's a concrete reason. Reflexively adding
"use client"orgetServerSidePropseverywhere is a red flag.The strongest interview answers pair a concept with a real scenario: "I've used ISR for product pages because inventory changes every few hours and we don't need real-time accuracy, but the static performance matters for SEO. We wired up on-demand revalidation to our CMS webhook so the page updates within seconds of an editor publishing."
That combination — understanding, judgment, and experience — is what separates candidates.
*Questions 1-10 cover foundations that appear in almost every Next.js interview. Questions 11-25 cover architecture and the App Router patterns that senior roles require. Questions 26-45 cover the operational, testing, and edge-case knowledge that separates strong mid-level from senior engineers.*
FAQ
What is the most important Next.js concept to understand for interviews?+
React Server Components and the client/server boundary. Most interview questions eventually connect back to understanding which code runs on the server, which runs in the browser, and how to pass data across that boundary correctly. Interviewers use this to gauge whether you understand the App Router's mental model inversion — the default is now server, and you opt into the client with 'use client'.
What is the difference between getServerSideProps and the App Router equivalent?+
In the Pages Router, getServerSideProps is an exported async function that runs on the server per request and passes data as props to the page component. In the App Router, you simply make your page component async and fetch data directly inside it using await — no special export needed. For SSR behavior (no caching), use fetch() with { cache: 'no-store' } or call unstable_noStore(). The App Router approach is cleaner because data fetching is co-located with the component that uses it.
When should you use 'use client' in a Next.js App Router project?+
Add 'use client' only when your component needs browser-specific APIs (window, document, localStorage), React hooks that require client state or effects (useState, useEffect, useContext, useRef), or event handlers (onClick, onSubmit, onChange). Everything else should default to Server Components. A common mistake is adding 'use client' to components that only need to render static output — this ships unnecessary JavaScript to the browser.
What is the difference between ISR and on-demand revalidation?+
ISR (Incremental Static Regeneration) with a revalidate time (e.g., revalidate: 60) regenerates a page in the background when a request comes in after the specified number of seconds has elapsed — the stale page is still served until the new one is ready. On-demand revalidation using revalidatePath() or revalidateTag() lets you trigger regeneration immediately when data changes — for example, from a CMS webhook. On-demand revalidation is more precise and results in fresher content, but requires an endpoint that your data source can call.
What are Server Actions and how are they different from API routes?+
Server Actions are async functions marked with 'use server' that run on the server but can be called directly from Client Components — including as HTML form action attributes. They eliminate the need to write a separate API endpoint for mutations. The key differences from API routes: Server Actions can be passed directly as form actions (progressive enhancement — they work without JavaScript), they co-locate mutation logic with the UI, and they automatically secure the server/client boundary with a secret token. API routes remain useful for webhooks, third-party callbacks, and when you need full HTTP control over headers and status codes.
How do you prevent a Server Component from being cached when you need fresh data?+
There are three approaches. First, use fetch() with { cache: 'no-store' } for any fetch calls inside the component. Second, use the unstable_noStore() function at the top of a Server Component or data function when you're not using fetch (e.g., direct ORM calls). Third, export dynamic = 'force-dynamic' from a page or layout file to opt the entire route out of static generation. The first two are preferred for granular control — they let you make one part of a page dynamic without making the whole route dynamic.
What is Partial Pre-Rendering (PPR) and why does it matter?+
PPR is an experimental Next.js rendering model that lets a single page have both a statically-generated shell (served instantly from a CDN) and dynamic holes filled with streamed content per request. Previously, any dynamic data on a page forced the entire page to be server-rendered on every request. With PPR, you wrap dynamic sections in Suspense boundaries — Next.js pre-renders everything outside those boundaries at build time and streams the dynamic parts in separately. This gives you CDN-level performance for the page shell with per-request freshness for personalized or real-time sections.
How does Next.js handle TypeScript out of the box?+
Next.js has zero-configuration TypeScript support — create a tsconfig.json (or just rename a file to .tsx) and Next.js automatically configures the compiler. It provides built-in types for all special files (page props, layout props, route handler params, generateMetadata, generateStaticParams) via the 'next' package. The Metadata type for SEO, NextRequest/NextResponse for route handlers, and PageProps types are all available without additional setup. For the strongest setup, pair this with @t3-oss/env-nextjs for type-safe environment variables and zod for runtime validation in Server Actions.
Related articles
How to Answer Conflict-With-a-Coworker Interview Questions
Learn how to answer conflict-with-a-coworker interview questions with real examples and proven techniques. Stand out in tech and remote job interviews.
How to Answer 'Why Do You Want to Work Here' in Interviews
Discover expert strategies for answering 'why do you want to work here,' tailored for remote tech roles and dollar opportunities. Real, practical interview tips.
Frontend Developer Interview Questions and How to Answer Them (50+)
Complete SEO article covering 54 frontend developer interview questions with detailed answers, real code snippets across HTML, CSS, JavaScript, React, TypeScript, accessibility, security, build tools, and testing.
Full-Stack Developer Interview Questions: How to Answer Like a Pro (45+)
Comprehensive full-stack developer interview guide with 46 numbered questions covering JavaScript/TypeScript, React, CSS, REST APIs, databases, Node.js, system design, security, testing, DevOps, and advanced architecture topics. Each answer includes working code examples and production-level context.
Prepare for your real interview
Paste your job link: we research who's interviewing you and rehearse you live.
Start free →Have an interview coming up? Install the live copilot →