Next.js App Router vs Pages Router: Interview Guide
How the App Router differs from the Pages Router in Next.js 15, how data fetching maps across, and how to migrate one route at a time.
The React framework interviewers ask about: App Router, Server Components and Server Actions, rendering strategies (SSR, SSG, ISR), caching, routing and deployment.
Official reference: Next.js docs
How the App Router differs from the Pages Router in Next.js 15, how data fetching maps across, and how to migrate one route at a time.
Request memoization, the Data Cache, the Full Route Cache and the Router Cache in Next.js 15, and how to debug a page that shows stale data.
Why React reports that server HTML did not match the client in Next.js 15, how to find the cause, and fixes for dates, browser APIs and nesting.
What Next.js 15 middleware can do, how matchers decide where it runs, and why real authorisation still belongs next to your data.
How Server Actions turn a function into a POST endpoint in Next.js 15, how to build forms with useActionState, and how to secure every action.
How Server and Client Components render in the Next.js 15 App Router, where to put use client, and how to keep secrets out of the bundle.
When Next.js 15 renders a page at build time, per request or with background regeneration, and how to prove which one a route uses.
How loading.tsx and Suspense stream a Next.js 15 page, where error.tsx catches failures, and why a slow layout or late notFound breaks the plan.
Both are file-system routers, but they are built on different React models. The Pages Router (pages/) renders every page as a Client Component that is also server-rendered, and fetches data with page-level functions such as getServerSideProps, getStaticProps and getStaticPaths.
The App Router (app/) is built on React Server Components. Components are server-only by default, any component can be async and fetch its own data, and layouts are nested and persist across navigations. It adds loading.tsx and error.tsx conventions, streaming with Suspense, Server Actions and a layered cache.
Both routers can live in one project during a migration, but a URL must be owned by only one of them. New projects should use the App Router; I would still expect to maintain Pages Router code in older apps.
Likely follow-up: How would you migrate one page from pages/ to app/ without a big-bang rewrite?
In the App Router every component is a Server Component unless a 'use client' directive says otherwise. Server Components run only on the server, at build time or per request. They can be async, read a database or secrets directly, and their code never ships to the browser. They cannot use state, effects, event handlers or browser APIs.
Client Components are pre-rendered to HTML on the server and then hydrated in the browser, so they can use useState, useEffect, event handlers and window. Their code is part of the JavaScript bundle.
The usual design is server components for data and layout, with small client components as interactive leaves. That keeps the bundle small and keeps secrets on the server.
Likely follow-up: Can a Client Component render a Server Component?
'use client' mark, and how can a Client Component still contain server-rendered content?mid'use client' marks a module boundary, not a single component. The file with the directive, and every module it imports, becomes part of the client bundle. You only need it at the entry point where the tree becomes interactive; children imported by that file are client code automatically.
A Client Component cannot import a Server Component, because the import would pull server code into the browser bundle. It can still render one when the Server Component is passed in as children or another prop from a server parent. The server renders that subtree first and sends the result as part of the RSC payload, so the client wrapper only places it.
Props crossing the boundary must be serializable by React: plain objects, arrays, dates, promises and Server Actions are fine, ordinary functions and class instances are not.
Likely follow-up: Why does putting use client in the root layout hurt performance?
In the App Router these are not separate APIs. A route is static unless it uses request data or opts out of caching, and revalidate turns static into ISR.
Likely follow-up: What happens to the first visitor after an ISR page goes stale?
By default Next.js prerenders a route at build time. It switches the route to dynamic rendering when, during that render, it sees something that only exists per request:
cookies(), headers(), draftMode(), connection(), or reading searchParams in a pagefetch with cache: 'no-store' or next: { revalidate: 0 }export const dynamic = 'force-dynamic' or revalidate = 0generateStaticParamsA plain fetch with no options does not make the route dynamic in Next.js 15. The route is still prerendered and that response is frozen into the HTML until the route is revalidated.
To check, read the next build route table: ○ is static, ● is SSG with generateStaticParams, ƒ is dynamic. export const dynamic = 'error' turns an accidental dynamic API into a build error.
Likely follow-up: Why might a page that reads Date.now() still show the same time for every visitor?
There are four, and each answers a different question:
GET fetches during a single render are deduplicated. React cache() does the same for database calls.fetch results that opt in with cache: 'force-cache' or next.revalidate. Invalidated by time or by revalidateTag / revalidatePath.When data looks stale I walk the layers from the browser inwards: is the route static, is the fetch cached, and did the mutation call revalidatePath or revalidateTag?
Likely follow-up: Which of these does router.refresh() clear?
Next.js 15 moved from cached-by-default to uncached-by-default in three places:
fetch is no longer stored in the Data Cache unless you pass cache: 'force-cache', set next.revalidate, or configure the segment to cache.GET Route Handlers are dynamic by default; export const dynamic = 'force-static' restores static output.staleTimes.dynamic: 0), so navigating back to a dynamic page fetches fresh data. Back and forward navigation and shared layouts still reuse the cache.When upgrading, an app that relied on implicit caching may hit its backend much more often. I would read the build output, then add explicit force-cache, revalidate or tags where the data really is shareable.
Likely follow-up: If fetch is uncached, why is a page with a plain fetch still marked static?
revalidatePath and revalidateTag.midTime-based revalidation sets a freshness window: export const revalidate = 60 on a segment or fetch(url, { next: { revalidate: 60 } }). After 60 seconds the next request still gets the stale copy, and Next regenerates in the background (stale-while-revalidate). It is simple but data can be up to one window old.
On-demand revalidation invalidates when the data actually changes. revalidatePath('/blog') marks a route's cached output stale. revalidateTag('posts') invalidates every fetch tagged with next: { tags: ['posts'] }, across all routes that used it. You call them from a Server Action or a Route Handler, for example a CMS webhook.
I use tags when one piece of data appears on many pages, paths when one page owns its data, and a long time window as a safety net.
Likely follow-up: How would you secure a webhook route that calls revalidateTag?
A Server Action is an async function marked with 'use server' that runs on the server but can be called from the client, typically as a form action or from an event handler. At build time Next replaces the function on the client with a reference ID. Calling it sends a POST to the current page with that ID, Next runs the function, and the response can carry a re-rendered RSC payload.
Because forms work as plain HTML posts, a form with a Server Action submits even before JavaScript loads. After the mutation, the action can call revalidatePath or revalidateTag and redirect, and the client receives fresh UI in the same round trip.
In React 19, useActionState gives you the returned state and a pending flag, and useFormStatus exposes pending state inside the form.
Likely follow-up: When would you still write a Route Handler instead?
Treat every Server Action as a public HTTP endpoint. Anyone who finds its ID can call it with curl and any arguments, whether or not the button is shown to them.
FormData values are untrusted strings.Next.js adds defences: action IDs are unguessable and change between builds, unused actions are dropped from the build, values captured in an inline action's closure are encrypted, and the framework compares the Origin header with the host to block cross-site posts. Behind a proxy you may need serverActions.allowedOrigins. These help, but none replaces an authorisation check.
Likely follow-up: Why should you avoid closing over secrets in an inline Server Action?
route.ts) instead of a Server Action?midServer Actions are for mutations triggered by your own UI. They are always POST, tied to the React tree and return data to the component that called them.
Route Handlers (app/api/.../route.ts, the App Router version of pages/api) export functions named after HTTP methods and use Web Request and Response. I use them when:
GET endpointA route.ts and a page.tsx cannot live in the same segment. In Next.js 15, GET handlers are dynamic unless you opt into static output.
Likely follow-up: Should a Server Component call your own Route Handler to get data?
Middleware is a single middleware.ts file at the project root (or in src/) that runs before a request reaches routes and caches. It receives a NextRequest and can redirect, rewrite to another path, set request or response headers and cookies, or return a response directly.
Good uses: redirecting signed-out users away from private areas, locale detection, A/B test bucketing with rewrites, and adding security headers. A config.matcher limits which paths it runs on, which matters because it otherwise runs for every request, including static assets and prefetches.
It has used the Edge runtime by default; the Node.js runtime is stable from Next.js 15.5. It should stay fast: no heavy database work on every request. Next.js 16 renames the file convention to proxy.ts.
Likely follow-up: Why is middleware not enough on its own to protect data?
Middleware is a good first gate for redirects, but it is one layer far from the data. Several things make it unsafe as the only check:
x-middleware-subrequest header until it was patched in 15.2.3 and backported releases.The recommended pattern is a data access layer: functions that verify the session and the user's permission right next to the query, called from pages, actions and handlers alike.
Likely follow-up: How would you share the session check between a page and a Server Action?
A folder becomes a route segment, and these files give it behaviour:
page.tsx: the UI for the URL; without it the segment is not routable.layout.tsx: wraps the page and child segments, persists across navigations. The root layout must render html and body.template.tsx: like a layout but remounts on every navigation.loading.tsx: wraps the page in a Suspense boundary and shows instantly while it streams.error.tsx: a Client Component error boundary for the segment.not-found.tsx: rendered when notFound() is called.route.ts: an HTTP endpoint instead of a page.default.tsx: fallback for parallel route slots.They nest in a fixed order: layout, template, error, loading, not-found, then the page.
Likely follow-up: Why does an error.tsx not catch errors thrown in the layout of the same segment?
layout.tsx and template.tsx?midA layout is rendered once and preserved while you navigate between its child routes. Its client state, scroll position in a sidebar and DOM are kept, and it does not re-render on navigation. That is why a layout cannot read the current pathname or searchParams as props: it would not update.
A template wraps children the same way but gets a new key for each navigation, so React unmounts and remounts it. State resets and effects run again.
Use a template when you need per-page enter animations, an effect that should fire on every navigation such as logging a page view, or a form that must reset. Otherwise prefer layouts, since preserved state and fewer re-renders are the point of nested routing.
Likely follow-up: How would a layout highlight the active nav link if it does not re-render?
loading.tsx and Suspense enable streaming in the App Router?midStreaming sends HTML in chunks. The server flushes the parts of the page that are ready and leaves placeholders for parts still waiting on data; when those finish, it streams the HTML plus a small script that swaps them into place.
loading.tsx is shorthand for wrapping the segment's page in a Suspense boundary with that fallback, so the layout and loading UI appear immediately. For finer control, wrap slow components in your own Suspense boundaries so a slow recommendations panel does not hold back the product details.
Benefits: faster time to first byte and first paint, and earlier hydration of the parts that are ready. A caveat: once the first chunk is sent the HTTP status is fixed, so a notFound() inside a streamed boundary cannot change it to 404.
Likely follow-up: Where would you call notFound() to guarantee a real 404?
error.tsx, global-error.tsx and not-found.tsx?miderror.tsx must be a Client Component, because it is a React error boundary. It wraps the segment's page and children and receives error and reset. It does not wrap the layout.tsx of the same segment, so a layout error bubbles to the parent's error.tsx. Errors in the root layout are caught by app/global-error.tsx, which replaces the root layout and must render its own html and body.
In production, errors thrown in Server Components reach the client with a generic message and a digest you can match against server logs, so internals do not leak.
notFound() throws a special error that renders the nearest not-found.tsx. Expected failures, such as validation errors in a Server Action, should be returned as values, not thrown.
Likely follow-up: What does reset() actually do?
Hydration attaches React to server-rendered HTML. React expects the first client render to produce the same markup; if it differs, React 19 reports that the server-rendered HTML did not match and re-renders that tree on the client (minified as error #418 in production).
Common causes:
Date.now(), Math.random(), locale or time zone formattingtypeof window during renderlocalStorage during renderdiv inside a pFixes: render the same thing on both sides, then update in useEffect; format dates with an explicit time zone; load client-only widgets with next/dynamic and ssr: false from a Client Component; and use suppressHydrationWarning only for a single unavoidable text node such as a timestamp.
Likely follow-up: Why is if (typeof window !== "undefined") in render a problem but fine inside useEffect?
A waterfall happens when independent requests run one after another: a page awaits the user, then awaits orders, then a child awaits recommendations. Each await in sequence adds a full round trip.
Fixes:
Promise.all, or Promise.allSettled if one may fail.use() inside a Suspense boundary, so the shell streams while data loads.cache() to deduplicate shared calls, instead of prop-drilling.I confirm with server timing logs or tracing that the slowest request, not their sum, sets the response time.
Likely follow-up: What is the downside of Promise.all if one request is much slower than the others?
getUser(). How many requests hit the backend, and how do you control that?hardIf getUser() uses fetch with the same URL and options, it hits the backend once: Next.js memoizes GET fetches for the duration of a single server render, even with cache: 'no-store'. The memo is discarded when the render ends, so the next request fetches again.
If getUser() queries a database or uses a client library, there is no automatic dedupe. Wrap it in React cache() to get the same per-request memoization:
export const getUser = cache(async (id) => db.user.find(id))Memoization only applies inside the React component tree, not in Route Handlers, and it is not cross-request caching. For that you need the Data Cache, unstable_cache, or the experimental 'use cache' directive. This is why fetching where data is used is fine in the App Router.
Likely follow-up: Why does cache() need to be called at module level and not inside the component?
params, searchParams, cookies() and headers() asynchronous in Next.js 15?midIn Next.js 15 these became Promises: const { slug } = await params and const jar = await cookies(). The reason is that the server can start rendering and prerendering everything that does not depend on the request, and only wait at the exact point where request data is read. That is the foundation for partial prerendering, where a static shell is served immediately and dynamic holes stream in.
For migration, Next.js 15 still lets you read them synchronously, but logs a warning in development such as "params should be awaited before using its properties". A codemod (npx @next/codemod@canary next-async-request-api .) rewrites most call sites. In Client Components, use React use(params) or the useParams and useSearchParams hooks.
Likely follow-up: How does generateMetadata receive params in Next.js 15?
generateStaticParams do, and what happens for a slug it did not return?midgenerateStaticParams is the App Router replacement for getStaticPaths. In a dynamic segment such as app/blog/[slug]/page.tsx it returns the list of params to prerender at build time, for example [{ slug: 'a' }, { slug: 'b' }]. The build output marks the route with ●.
For a slug not in the list, behaviour depends on dynamicParams:
true (default): the page is rendered on the first request and then cached like the others, similar to fallback: 'blocking'.false: unknown slugs return 404, like fallback: false.For very large catalogues, a common approach is to return only the most popular slugs, or even an empty array, and let the rest render on demand. That keeps build times manageable.
Likely follow-up: How would you revalidate one blog post after it is edited in a CMS?
next/image component do for you?easyImage from next/image wraps the img element with optimisations:
srcset so the browser downloads a size that fits the viewport, served by the image optimisation endpoint.images.formats.width and height, or fill with a sized parent, reserve space and prevent layout shift.For the hero or LCP image, set priority so it is preloaded instead of lazy-loaded. Remote images must be allowed in images.remotePatterns so the optimiser cannot be abused as an open proxy. With output: 'export', the default loader is unavailable, so you need a custom loader or unoptimized.
Likely follow-up: Why is the sizes prop important when using fill?
next/link make navigation fast?easyLink renders an a element, so it works without JavaScript and is crawlable. Once the app hydrates, clicks are intercepted for client-side navigation: the router fetches only the RSC payload for the segments that change, keeps shared layouts mounted and preserves their state.
In production, links are prefetched when they scroll into view. For static routes the whole route is prefetched; for dynamic routes it prefetches down to the nearest loading.tsx boundary, so the loading UI appears instantly on click while the rest streams. Prefetching is disabled in development.
Set prefetch={false} for long lists of rarely used links to save bandwidth. For programmatic navigation in a Client Component, use useRouter().push(), and router.prefetch() to warm a route manually.
Likely follow-up: Why might prefetching overload an API if a page lists 500 links?
Use the Metadata API in a layout.tsx or page.tsx, which must be Server Components:
export const metadata = { title, description, openGraph }export async function generateMetadata({ params }) when it depends on data, such as a product nameMetadata merges from the root layout down, so the root can set title.template like %s | Shop and metadataBase for absolute URLs, while pages fill in their own title. A fetch inside generateMetadata is memoized with the page's identical fetch, so the data is not loaded twice.
File conventions cover the rest: opengraph-image.tsx, icon.png, sitemap.ts and robots.ts. The Pages Router equivalent is the Head component from next/head.
Likely follow-up: Why can metadata not be exported from a Client Component?
NEXT_PUBLIC_?easyNext.js loads .env, .env.local, .env.development and .env.production (plus their .local variants) into process.env on the server. Variables already set in the real environment take precedence, and .env.local is skipped when NODE_ENV is test.
By default variables are server-only: reading process.env.DB_URL in a Client Component gives undefined. Prefixing a variable with NEXT_PUBLIC_ makes it available in the browser, but the value is inlined into the JavaScript bundle at build time. Changing it later in the deployment environment has no effect until you rebuild, and anyone can read it.
So: never put secrets behind NEXT_PUBLIC_, and if you build one image for several environments, read config at runtime on the server and pass what the client needs as props.
Likely follow-up: How would you run the same Docker image in staging and production with different public API URLs?
Parallel routes use named slots: folders like @modal or @analytics. Each slot is passed to the parent layout as a prop alongside children and navigates independently. When a slot has no match on a full page load, Next renders its default.tsx, so you usually add one returning null.
Intercepting routes use (.), (..) or (...) folder prefixes to render a different route in the current layout during client navigation. app/@modal/(.)photo/[id]/page.tsx intercepts /photo/123 when you click a thumbnail.
Together: clicking a photo shows it in a modal over the feed, the URL becomes /photo/123 and is shareable, back closes the modal, and a refresh or direct visit renders the full app/photo/[id]/page.tsx instead.
Likely follow-up: Why does the modal disappear when the user refreshes?
[slug], [...slug], [[...slug]] and (group) folders mean in the App Router?easy[slug]: a dynamic segment. /blog/hello gives params as { slug: 'hello' }.[...slug]: catch-all. /docs/a/b gives { slug: ['a', 'b'] }; /docs alone does not match.[[...slug]]: optional catch-all. Also matches /docs, with slug undefined.(group): a route group. The folder name is left out of the URL, so app/(marketing)/about/page.tsx serves /about. Groups let you give sections different layouts, or several root layouts, without changing URLs._folder: a private folder, excluded from routing, handy for colocated components.In Next.js 15, params is a Promise in pages and layouts, so you write const { slug } = await params.
Likely follow-up: What happens if two route groups both define /about?
Server Components are not shipped to the browser, but a shared utility can be imported into a Client Component by mistake, and then it is bundled. Three safeguards:
import 'server-only' at the top of the module. If any client module imports it, the build fails with a clear error instead of silently leaking.NEXT_PUBLIC_ variables, which are undefined in the browser.Next.js also has experimental React taint APIs (experimental.taint) that throw if a marked object or value is passed to a Client Component. The mirror package client-only marks modules that must never run on the server.
Likely follow-up: Why are props passed to a Client Component visible in the page source?
next start supports every feature, and output: 'standalone' produces a minimal server folder that fits well in a Docker image. The surprises come with more than one instance:
cacheHandler (for example Redis) and usually cacheMaxMemorySize: 0.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on every instance and use a consistent build ID.output: 'export' is a different choice: pure static files with no server features.
Likely follow-up: Which features stop working with output: export?
No questions match that filter.
Prefer multiple choice? All 20 Next.js MCQs with answers →