This page is for developers facing a Next.js round, from a first frontend job to a senior full-stack role. Most interviews open with the App Router and how it differs from the Pages Router, then move to server and client components, rendering modes, and the caching rules that trip people up in production. Stronger rounds add server actions, middleware, SEO, performance and self-hosting, plus a story from your own work and a judgement call. Each question shows what the interviewer is really checking, the shape of a strong answer and a short answer you can say out loud.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Pages Router: files in pages/ become routes; data comes from getServerSideProps or getStaticProps; every component ships to the browser.
App Router: folders in app/ define routes; components are Server Components by default and can await data themselves.
Extras: nested layouts that keep state, per-segment loading and error files, and streaming.
Choice: new projects on the App Router; both routers can live in one app while migrating.
"In the Pages Router, each file under pages is a route, and data loading happens in special functions like getServerSideProps or getStaticProps that run before the page renders and pass props in. Every component on the page is also sent to the browser as JavaScript. The App Router, under the app folder, is built on React Server Components. Components run on the server by default, so I can make the component itself async and await my data right there, and only the parts I mark with 'use client' ship JavaScript. It also gives me nested layouts that keep their state between navigations, loading and error files per segment, and streaming. For a new project I'd pick the App Router, since that's where new features land. The Pages Router is still supported, and both can live in one app, which matters when migrating."
Describing the App Router as just a new folder name, without mentioning Server Components or how data loading changed.
page and layout: page makes the segment reachable; layout wraps it and its children and stays mounted across navigation.
loading and error: loading becomes a Suspense fallback; error becomes an error boundary and must be a Client Component.
not-found: shown when notFound() is called or no route matches.
"A folder only becomes a public URL when it has a page file. The layout wraps that page and everything nested below it, and the nice part is that layouts don't re-render or lose state when you move between sibling pages, so a sidebar or a player keeps going. The loading file is shown instantly while the segment's content loads, because Next wraps the page in a Suspense boundary with it as the fallback. The error file is an error boundary for that segment, so one broken section shows a fallback with a retry button instead of taking down the whole app, and it has to be a Client Component. The not-found file renders when I call notFound(), say when a product ID doesn't exist, or when nothing matches the URL. The root layout is the one that's required, and it has to render the html and body tags."
Thinking every folder under app is automatically a route, or not knowing that a layout keeps its state between navigations.
[id]: matches exactly one segment, like /products/42, and arrives as params.id.
[...slug]: catch-all; matches one or more segments and gives an array.
[[...slug]]: optional catch-all; also matches the bare path with no extra segments.
"Square brackets in a folder name make that segment dynamic. So app/products/[id]/page.tsx matches /products/42, and the page receives params with id equal to the string '42'. A catch-all, [...slug], matches one or more segments: for a docs site, /docs/setup/install gives me slug as an array of setup and install. But it won't match plain /docs. The optional catch-all, with double brackets, matches that bare /docs as well, and slug is just undefined there. In recent versions params is a promise, so in the page I await it before reading the values. And params are always strings, so if I need a number I convert and validate it, and I call notFound() if the record doesn't exist."
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function Page(
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <h1>{post.title}</h1>;
}
Forgetting that params values are strings, or assuming a catch-all segment also matches the parent path.
Purpose: returns the params to prerender at build, the App Router's version of getStaticPaths.
Unknown slugs: by default rendered on demand at the first request, then cached.
Lock down: export dynamicParams = false to return a 404 for anything not in the list.
"generateStaticParams runs at build time and returns an array of params objects, say one per blog post slug. Next then prerenders each of those pages as static HTML. It replaces getStaticPaths from the Pages Router. For a slug that wasn't in the list, the default is that Next renders it on demand the first time someone asks for it, and after that it can be served from cache like the others. That's useful for a huge catalogue: I prebuild the most visited products to keep builds fast and let the long tail fill in on demand. If the list is complete and anything else should be a 404, I export dynamicParams set to false from the route. One thing to watch: if the page reads something request-specific like cookies, it's rendered dynamically anyway, so the static list doesn't help."
Saying unknown slugs always return a 404, or prebuilding every page and letting the build crawl.
Route groups: a folder in parentheses organises routes or gives them their own layout without changing the URL.
Parallel routes: @slot folders render several pages side by side in one layout, each with its own loading and error states.
Intercepting routes: (.) folders show a route inside the current page, like a modal, while a direct visit shows the full page.
"Route groups are folders in parentheses, like (marketing) and (app). They don't appear in the URL, so I use them to give the marketing pages one layout and the logged-in area another. Parallel routes are named slots, folders starting with @, like @analytics and @team. The parent layout receives each slot as a prop and renders them together, and each can load or fail on its own. I also add a default file so a slot knows what to show when the URL doesn't match it. Intercepting routes let me catch a navigation and show it in context. The classic case is a photo feed: clicking a photo opens it in a modal with the URL /photo/5, but if you refresh or share that link, you get the full photo page. A parallel @modal slot plus an intercepting route is how I build shareable modals."
Thinking route groups change the URL, or building a URL-driven modal with client state only so the link can't be shared.
Incremental: both routers run in one app, so move route by route, starting with simple, low-traffic pages.
Foundations first: root layout, shared providers, and auth and data helpers that work in both worlds.
Guardrails: compare performance and errors before and after each route, and keep a rollback path.
"I'd push back on a big rewrite and plan it route by route, because both routers can live in the same app. First, I'd upgrade Next and React and fix anything that breaks, as its own release. Then I'd build the app folder foundations: a root layout, a Providers component for our existing contexts, and data functions that both routers can call, so we aren't writing everything twice. Then I'd move pages in order of risk, starting with simple static pages like about or docs, then content pages, and last the complex, highly interactive ones like checkout. Each move is its own small PR: replace getServerSideProps with data fetched in the server component, split out the client parts, and compare metrics and errors before and after. Feature work carries on in whichever router the page currently lives in, and a route only ever exists in one router."
Proposing to freeze features and rewrite the whole app on the App Router in one go.
Server default: components in app/ run on the server, can be async, can read data directly and send no JavaScript for themselves.
Client triggers: state, effects, event handlers, browser APIs like window or localStorage, and libraries that need them.
Placement: put 'use client' as low in the tree as possible, on the small interactive pieces.
"In the App Router, every component is a Server Component unless I say otherwise. It renders on the server, it can be an async function that queries the database or calls an API directly, and its own code never ships to the browser, which keeps the bundle small and keeps secrets on the server. I add 'use client' at the top of a file when the component needs interactivity: useState or useEffect, an onClick handler, browser-only things like window or localStorage, or a library that uses those. A Client Component is still rendered to HTML on the server for the first load; it just also hydrates in the browser. My rule is to keep 'use client' at the leaves: the product page stays on the server and only the add-to-cart button becomes a Client Component."
Saying Client Components only ever render in the browser, or putting 'use client' on whole pages out of habit.
Boundary: the directive marks an entry point; every module that file imports becomes client code too.
No direct import: a Client Component can't import a Server Component and keep it on the server.
Composition: pass the Server Component in as children or another prop from a server parent.
"'use client' isn't a label on one component, it's a boundary. Everything that file imports gets pulled into the client bundle as well, so if a client component imports a big markdown renderer, that renderer ships to the browser even if it didn't need to. It also means I can't import a Server Component from a client file and have it stay on the server. The fix is composition. Say I have a client Tabs component that tracks which tab is open. In a server page, I render Tabs and pass the server-rendered panels to it as children. The server renders those panels first, and Tabs just receives the finished result as a slot. It can show or hide them, but their code never goes to the browser. Same idea for a client context provider wrapping server content in the root layout."
Believing 'use client' affects only the one component, so the bundle quietly grows with everything it imports.
Cause: the first render in the browser produced different output from the HTML the server sent.
Common sources: dates, random values, reading window or localStorage during render, locale or time zone formatting, invalid HTML nesting, browser extensions.
Fixes: make the first render deterministic, move browser-only values into an effect, or load that piece on the client only.
"A hydration error means React took the HTML the server sent, rendered the same component in the browser, and got something different. The usual suspects are rendering Date.now or Math.random, checking typeof window or reading localStorage during render, formatting dates where the server and browser time zone or locale differ, and invalid HTML, like a div inside a p tag, which the browser quietly rearranges. Browser extensions that inject markup can cause it too. My fix depends on the cause. For browser-only values, I render a stable placeholder first and set the real value in useEffect. For a widget that can't work on the server at all, I load it with next/dynamic and ssr set to false from a Client Component. For a timestamp that's expected to differ, suppressHydrationWarning on that one element is fine, but I don't sprinkle it around."
Turning off server rendering for the whole page, or adding suppressHydrationWarning everywhere, just to make the error go away.
Why not: the layout becomes client code, so its imports ship to the browser and it can't be async or export metadata.
Fix: a small Providers client component that wraps children, rendered by the server layout.
Tone: explain the why, pair on the change, and write the pattern down so it doesn't come back.
"I'd thank them for unblocking it, then explain why I'd do it differently. The error happens because context providers use React state, so they need to be client code. But putting 'use client' on the root layout makes the layout itself client code: everything it imports ships to the browser, it can't be async to fetch data, and it can no longer export metadata, which would quietly break our page titles. The standard fix is a tiny Providers file with 'use client' that wraps its children in the theme provider. The root layout stays a Server Component and just renders Providers around children, and the pages passed through as children stay Server Components. I'd pair with them for ten minutes to make that change, show that the metadata comes back, and suggest we add the pattern to our contributing guide."
Accepting the change because it works, without noticing the lost metadata and the larger bundle.
SSG: HTML built at build time; fastest, but only as fresh as the last build.
SSR: HTML built on every request; always fresh and can be personal, but costs server time per visit.
ISR: static pages that regenerate in the background after a time window or on demand.
Decision: personal or real-time data means SSR; shared content that changes sometimes means ISR; rarely changing means SSG.
"With static generation, the HTML is built at build time and served from a CDN, so it's as fast as it gets, but it only changes when I rebuild. Server-side rendering builds the HTML on every request, so it's always fresh and can depend on the user, like reading their session cookie, but every visit costs server work. ISR sits in between: the page is static, but I set a revalidate time, and after that window the next request still gets the cached page while Next regenerates it in the background, so later visitors get the new version. I can also trigger it on demand when content changes. So a marketing page is static, a product page is ISR, and a dashboard with the user's own orders is SSR. In the App Router these come from how the route fetches and caches its data, not separate APIs."
Choosing SSR for everything to be safe, or thinking ISR makes the first visitor after the window wait for the rebuild.
Default: a route is prerendered at build unless something in it needs the incoming request.
Triggers: cookies(), headers(), searchParams, fetches with cache: 'no-store', or segment config like dynamic = 'force-dynamic'.
Check: read the build output, which marks each route static or dynamic, and trace the import that reads request data.
"At build time Next tries to prerender each route. If, while rendering, it hits something that can only be known per request, the route becomes dynamic. The obvious triggers are reading cookies or headers, using searchParams, opting a fetch out of caching with no-store, or setting the route's dynamic config to force-dynamic. The quiet ones are the problem. An auth helper in the root layout that reads the session cookie makes every page under it dynamic. So when a team says their blog got slow, I check the build output, which labels each route static or dynamic, then trace what's reading request data. The fix is usually to move that read out of the shared layout, for example loading the user's name on the client after the page arrives, so the rest stays static. With partial prerendering, a Suspense boundary around the dynamic piece lets the static shell still be prebuilt."
Thinking static or dynamic is chosen once for the whole project, or never checking the build output to see what really happened.
Streaming: the server sends HTML in chunks as each part is ready instead of waiting for the whole page.
loading file: wraps the segment's page in a Suspense fallback, shown instantly on navigation.
Suspense: wrap just the slow component so the fast parts render first and it fills in later.
"Without streaming, a server-rendered page waits for its slowest query before sending anything. With streaming, the server sends the shell and whatever is ready immediately, then sends the rest as each part finishes, over the same response. In the App Router I get that two ways. A loading file in a segment becomes the Suspense fallback for that page, so on navigation the user sees a skeleton straight away instead of a frozen screen. For finer control, I wrap just the slow piece in Suspense. On a product page, the details come from a fast query and render right away, while the reviews component, which calls a slow service, sits in its own Suspense boundary with a small skeleton and streams in when it's done. The key is that the slow component fetches its own data, so the page itself isn't awaiting it."
Awaiting every query at the top of the page and then wrapping it in Suspense, which streams nothing.
Request memoization: identical fetch calls in one render pass run once.
Data Cache: fetch results kept on the server across requests until revalidated.
Full Route Cache: the rendered output of static routes, made at build time or on revalidation.
Router Cache: the browser keeps visited route payloads in memory for fast back and forward navigation.
"I think of four layers. Request memoization is per render: if the layout and the page fetch the same URL, it runs once, and for database calls I get the same effect by wrapping the function in React's cache. The Data Cache is on the server and outlives the request: a fetch result can be stored and reused until its revalidate time, or until I call revalidateTag or revalidatePath. The Full Route Cache stores the rendered output of static routes, so no code runs at request time at all. And the Router Cache lives in the browser, holding route payloads so back and forward feel instant. To opt out, I set cache to no-store on the fetch, make the route dynamic so it skips the route cache, and call router.refresh on the client. The defaults changed between major versions, fetch stopped being cached by default, so I always check which version a project runs."
Blaming 'the cache' without knowing which one, or fixing staleness by making every route dynamic.
Diagnose: the page or its fetch is cached, so the write never reaches it.
Invalidate on write: call revalidatePath or revalidateTag in the Server Action or Route Handler that saves the change.
Tag fetches: tag the product fetch so every page that shows that product refreshes together.
"The page is being served from cache, either the Data Cache holding the old fetch result or the prerendered route. Lowering revalidate to a few seconds would hide it but waste work on every product. The right fix is on-demand revalidation at the moment of the write. In the Server Action that saves the product, after the database update succeeds, I call revalidatePath for that product's URL. Better still, I tag the product fetch with something like product-42 and revalidate that tag, because the same product also shows up on category pages and search results, and a tag refreshes all of them at once. If the edit happens in a separate CMS, I expose a Route Handler as a webhook, check a shared secret, and revalidate there. Then I test it on a production build, since dev mode doesn't cache the same way."
'use server';
import { revalidatePath } from 'next/cache';
export async function saveProduct(id: string, formData: FormData) {
// check the caller is an admin before writing
await db.product.update({
where: { id },
data: { name: String(formData.get('name')) },
});
revalidatePath(`/products/${id}`);
}
Setting every page to no-store or a tiny revalidate time instead of invalidating when the data actually changes.
Spot it: each await blocks the next even though none needs the others' results.
Parallel: start all three together and await them with Promise.all.
Stream: if one call is much slower, move it into its own component behind Suspense.
"Each await pauses the function, so three one-second calls take three seconds even though none depends on the others. The first fix is to start them together and await Promise.all, so the page waits only as long as the slowest call. If one call is much slower than the rest, say recommendations, I go further: I move it into its own async component, wrap it in Suspense with a skeleton, and let it stream in, so the main content isn't blocked at all. I also watch for waterfalls across components, where a parent awaits its data before rendering a child that only then starts its own fetch. That's the same problem spread over files. And if one failed call shouldn't break the whole page, I use Promise.allSettled and render a fallback for the part that failed."
export default async function Dashboard() {
const [user, orders, alerts] = await Promise.all([
getUser(),
getOrders(),
getAlerts(),
]);
return <DashboardView user={user} orders={orders} alerts={alerts} />;
}
Moving the fetches into useEffect on the client, which swaps a server waterfall for a slower one in the browser.
Situation: what users saw and how it was reported.
Investigation: reproduce on a production build, then rule out each cache in turn.
Fix and lesson: the change you made and what stopped it happening again.
"At my last company, after editors updated a product in the CMS, the listing page kept showing old details for hours, though the product page was fine. It never happened on anyone's laptop, because dev mode doesn't cache the same way, so I first reproduced it with a production build locally. Then I went layer by layer. A hard refresh still showed old data, so it wasn't the browser's router cache. Server logs showed the listing's API call never ran, so the old data was coming from Next's server-side cache. That version of Next cached fetch by default, and the listing fetch had no revalidate or tag, so it stayed cached until the next deploy. The product page's fetch had a tag that the CMS webhook revalidated. I tagged the listing fetch too, and added a review rule: every fetch states its caching on purpose."
A story where the fix was turning off caching everywhere, with no idea why the data was stale.
Definition: an async function marked 'use server' that runs on the server but can be called from a form or client code.
Form: pass it to the form's action prop; it receives the FormData, and it works before JavaScript loads.
After the write: validate, save, then revalidate or redirect; show pending and errors with useFormStatus or useActionState.
"A Server Action is an async function with 'use server' that runs only on the server, but I can hand it to a form or call it from a Client Component as if it were local. Under the hood Next turns the call into a POST request. For a subscribe form, I write an action that takes the FormData, reads the email, validates it, saves it and then calls revalidatePath or redirect. In the page, I set the form's action to that function. If the form is rendered by a Server Component, it even works before the JavaScript has loaded, which is progressive enhancement for free. For pending states and error messages, I use useFormStatus inside the submit button, or useActionState to get back what the action returned, like a validation message to show next to the field."
// app/subscribe/actions.ts
'use server';
import { redirect } from 'next/navigation';
export async function subscribe(formData: FormData) {
const email = String(formData.get('email') ?? '').trim();
if (!email.includes('@')) return;
await db.subscriber.create({ data: { email } });
redirect('/subscribe/thanks');
}
// in a server page:
// <form action={subscribe}><input name="email" /><button>Join</button></form>
Thinking a Server Action is private because it isn't a visible API route, and skipping validation.
Server Actions: mutations triggered from your own React UI, like forms and buttons.
Route Handlers: route.ts files exporting GET, POST and so on, using the standard Request and Response.
Pick handlers for: webhooks, mobile or third-party clients, public APIs, file downloads, feeds and other non-page responses.
"Server Actions are for my own UI changing data: a form submit, a like button. Next wires up the request, and they work neatly with revalidation and redirects. Route Handlers are route.ts files where I export functions named after HTTP methods, and they take a standard Request and return a Response. I reach for them when the caller isn't my React UI: a payment provider's webhook, a mobile app hitting a JSON API, a cron job, an RSS feed, a CSV export or an OAuth callback. They give me full control over the URL, method, status codes and headers, which actions don't. I also avoid calling my own Route Handlers from Server Components; that's an extra network hop to my own server, so I call the shared data function directly. And a folder can't have both a page and a route file at the same level."
Fetching your own Route Handlers from Server Components, adding a pointless network round trip.
What it is: code that runs before a request reaches a route, with a matcher to choose which paths.
Good for: redirects, rewrites, headers and cookies, locale detection, A/B routing, a quick check for a session cookie.
Avoid: slow database calls on every request, and making it the only place authorization happens.
"Middleware runs before the request is matched to a page, on every path its matcher covers. It can redirect, rewrite to a different route, or add headers and cookies, and then let the request carry on. I use it for things like sending users to their locale, redirecting old URLs, splitting A/B test traffic, or bouncing someone without a session cookie to the login page. What I avoid is heavy work, since it runs on nearly every request and adds latency to all of them, so no big database queries. And I never rely on it as the only authorization check. There was a real vulnerability where a crafted header let requests skip middleware on some self-hosted versions, and it's a good lesson: the data layer and each Server Action should still check the user. In the newest versions the file is called proxy, but the idea is the same."
Doing all authorization in middleware and trusting every request that gets past it.
Risk: the action can be called with a direct POST, so a check on the page protects nothing.
Fix: read the session inside the action, use the session's user ID, validate input, and refuse by default.
Process: raise it now, keep the fix small and tested, and audit the other actions for the same gap.
"I treat it as a blocker. A Server Action becomes an endpoint the browser calls with a POST request, so anyone who replays that request can call it without ever loading the protected page. If the action takes an account ID from the form, someone could delete another user's account. I'd flag it to the lead straight away rather than quietly patch it. The fix is small: inside the action, read the session, reject if there's no user, and ignore any account ID sent from the client, using the session's user ID instead. I'd add a test that calls the action without a session and expects a refusal. Then I'd search every 'use server' file and check each action does its own authorization. Longer term I'd suggest a shared helper, so every action starts with the same check."
Deciding it's fine because the delete button only appears on a protected page.
Static: export a metadata object from a layout or page.
Dynamic: export generateMetadata, which receives params and can fetch the post.
Merging: child segments override parent fields; a title template in the root layout adds the site name.
"For static pages I export a metadata object from the page or layout with the title, description and openGraph fields, and Next writes the head tags for me. For a blog post, I export an async generateMetadata function. It receives the params, fetches the post, and returns the title, a description from the excerpt, and the cover image for Open Graph. The fetch is memoized, so if the page fetches the same post it isn't called twice; for a direct database call I'd wrap it in React's cache. Metadata merges down the tree, so in the root layout I set a title template that adds the site name after each page's title, and each page only sets its own part. I also add a canonical URL through alternates, and generate the sitemap and robots files with sitemap.ts and robots.ts in the app folder."
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.coverUrl] },
};
}
Setting titles in a client-side effect or with hand-written head tags, so crawlers and link previews miss them.
Benefits: images resized per device in modern formats, lazy loading by default, and reserved space to prevent layout shift.
Required info: width and height, or fill inside a sized parent; a sizes prop for responsive images.
Mistakes: lazy-loading the hero image, missing sizes, and not allowing remote hosts in the config.
"next/image serves each device an image resized to what it needs, in a modern format like WebP when the browser supports it, and it lazy-loads images below the fold by default. Because it needs a width and height, or the fill prop inside a positioned parent, the browser reserves the space and the layout doesn't jump when the image arrives, which helps CLS. The mistakes I see: leaving the large hero image lazy-loaded, which hurts LCP, so I tell Next to load that one eagerly with high priority. Forgetting the sizes prop on responsive or fill images, so phones download a desktop-sized file. And loading images from a CMS without adding its host to remotePatterns in the config, so they fail. When self-hosting, optimisation runs on your own server, so I keep an eye on CPU and caching there too."
Using next/image for the hero without marking it high priority, then wondering why LCP got worse.
Self-hosted: font files are downloaded at build time and served from your own domain, with no request to a font provider.
Less shift: a fallback font is adjusted to match the web font's size, so text barely moves when it swaps.
Scoped use: load it once in a layout and apply it through a className or a CSS variable.
"With a normal stylesheet link to a font provider, the browser fetches CSS from another domain, then the font files, and when the font finally arrives the text reflows because the fallback was a different size. next/font moves that work to build time. For Google Fonts, it downloads the files and serves them from my own domain, so there's no third-party request from the visitor's browser. It also generates a fallback font with adjusted size metrics, so the swap barely shifts the layout. I call the font function once, usually in the root layout, pick only the subsets and weights I need, and apply it with the className it returns, or as a CSS variable if I'm using Tailwind. For fonts I have as files, next/font/local does the same job."
Loading every weight and subset of a font, or adding a separate stylesheet link on top of next/font.
Measure: run the bundle analyzer on a production build to see which modules dominate each route.
Common causes: 'use client' too high in the tree, heavy libraries in client files, barrel files pulling in whole packages.
Fixes: push client boundaries down, move work to Server Components, lazy-load with next/dynamic, load third-party scripts with next/script.
"I start by measuring. I run the bundle analyzer on a production build, which gives a treemap of which modules take the space on each route, and I check the network tab for what the page really downloads. Then I look for the usual culprits. Most often, 'use client' sits on a whole page or layout, so everything below it ships to the browser, and pushing it down to the interactive leaves removes a lot. Next come heavy libraries in client files: a date library, a chart library or a markdown parser. If that work can happen on the server, I move it into a Server Component so it never ships. If it's needed in the browser but not right away, like a chart below the fold or a rich editor in a modal, I load it with next/dynamic. Third-party scripts like chat widgets go through next/script with a lazy strategy. Then I re-run the analyzer and compare."
Optimising by guesswork, or wrapping everything in next/dynamic without first checking what's actually big.
Problem: what was wrong, like generic titles, blank previews or pages not indexed.
Changes: metadata, rendering mode, sitemap, canonical URLs.
Proof: how you checked it, like the raw HTML, a link preview checker or search console data.
"At my last company, shared links to our articles showed the site's generic title and no image, and many articles weren't getting indexed. The article page fetched its content in a client component with useEffect, so crawlers and link preview bots saw an empty shell and the default metadata. I moved the fetch into the Server Component, added generateMetadata to build the title, description and Open Graph image from each article, and made the pages ISR so they stayed fast and still updated. I also added a sitemap.ts listing every article with its last-modified date, and canonical URLs so query strings didn't create duplicate pages. I checked each change by viewing the raw HTML with JavaScript turned off and running links through a preview checker. Over the next few weeks, search console showed far more articles indexed, and shared links looked right."
Claiming SEO improved without any way of checking what crawlers actually received.
Server-only by default: plain variables are readable in server code and never sent to the browser.
NEXT_PUBLIC_ prefix: the value is inlined into the client bundle at build time.
Consequence: changing a public value needs a rebuild; secrets must never carry the prefix.
"Next loads variables from .env files and the real environment. By default they're only available in server code, like Server Components, Route Handlers and Server Actions, so an API key stays on the server. If I want a value in the browser, I prefix it with NEXT_PUBLIC_, and at build time Next replaces every reference with the literal value inside the JavaScript. That's the catch: the value is baked in when the build runs. If I change it in production and just restart, the browser code still has the old one, because nothing rebuilt it. So public values need a rebuild per environment, or I read them on the server at request time and pass them down as props. Anything with the prefix is visible to anyone who opens dev tools, so it's for public keys only. I also import server-only in modules that hold secrets, so a client import fails the build."
Putting a secret key behind NEXT_PUBLIC_ to fix an undefined value in a Client Component.
Node.js runtime: full Node APIs and the whole npm ecosystem; the default for routes.
Edge runtime: a smaller set of web-standard APIs, quick to start and close to users, but no file system and many packages won't run.
Choice: weigh where the data lives, which libraries you need, and how much start-up time matters.
"The Node.js runtime is the default. It has the full Node API, the file system, native modules and every database driver, so it just works. The Edge runtime is a lighter environment built on web-standard APIs like fetch, Request and Response. It starts quickly and can run in many locations close to the user, but there's no file system, many Node libraries don't run, and there are tighter limits on code size and execution. The trap is thinking edge is always faster. If an edge function near the user calls a database on another continent, every query crosses that distance, and a few queries in a row can be slower than a Node function sitting next to the database. So I use edge for light work that doesn't touch the database, like redirects, geolocation or header logic, and keep data-heavy routes on Node, close to the data."
Moving every route to the edge for speed without looking at where the database lives.
Build: standalone output for a small image that runs with node server.js, or a static export if no server features are needed.
Shared cache: the ISR and data cache sit in each instance's memory and disk by default, so several instances need a shared cache handler.
Around it: a CDN for static assets, image optimisation load on your servers, and the same build on every instance.
"I set output to standalone in the config, which traces only the files the server needs, so the Docker image copies that folder plus the static assets and runs node server.js. If the site had no server features at all, a static export to plain files would be simpler. The part that bites teams is caching. By default the ISR and data cache live in each instance's memory and on its local disk. With three containers behind a load balancer, revalidating on one leaves the other two serving old pages, and a new container starts cold. So I configure a custom cache handler backed by something shared, like Redis. I also put a CDN in front for the hashed static files, make sure every instance runs the same build so users don't get mismatched assets mid-deploy, and watch CPU, because image optimisation now runs on my own servers."
Scaling to several containers without a shared cache and then serving different content from each.
The break: what failed and who noticed.
The gap: what differs between dev and production, such as build-time values, static rendering, runtime or caching.
Prevention: the check you added, like a production build in CI or a preview deploy.
"In my final-year project, I changed a feature flag and the new banner didn't show in production, though it worked perfectly with next dev. The flag was read from a NEXT_PUBLIC_ variable in a Client Component. I'd changed the value in the hosting settings and restarted, but public variables are inlined into the bundle at build time, so the old value was baked into the JavaScript. I confirmed it by searching the built files and finding the old value there. The quick fix was a rebuild, but the real lesson was that anything I want to change without a build shouldn't live in a public variable. I moved the flag to a server-read variable and passed it to the component as a prop. After that I added a production build and a quick smoke test on a preview deploy before merging, because dev mode hides a lot of these differences."
Blaming the hosting platform without being able to say what actually differed between local and production.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.