Every question in Next.js reduces to one: where does this code run, and when? A component runs on the server or in the browser. A page renders at build time or on every request. A value comes from a cache or the source. Get those three axes right and the framework is small; get them wrong and you meet its whole error surface — a hook that refuses to run, a secret in a bundle, a stale page. This guide takes them in order, naming the mistake at each. Everything below was executed against Next.js 16.2.12; caching changed shape in version 16, so versions are stamped.
Each chapter opens with the short version. Tap one to read the detail.
The folder tree is the router
~2 min
Creating a folder does not create a route — a segment goes live only when it holds a page file, which is why everything else in that folder is safe from the router. And a layout does not re-render when you navigate inside it, so work you put there runs once for the whole section.
Two beliefs cause most early confusion. The first is that a folder is a route: app/dashboard/ answers nothing until it holds a page. Folders define the path, files define what renders in it, and a folder with neither is unreachable — which is why helpers can sit beside the page that uses them without becoming pages.
The second is that a layout re-runs as you move around inside it. It does not: navigating between two pages under /blog keeps the blog layout mounted, interactive and holding its state. Work placed there expecting to repeat per page — a fetch, a permission check, an analytics call — happens once for the whole section instead. Per-page work belongs in the page.
The root layout is required and must render <html> and <body> itself. Two syntaxes then break the folder-equals-segment rule so you can organize without touching public URLs:
app/
(marketing)/page.tsx "/" group never appears in the URL
blog/
layout.tsx wraps /blog and everything below
[slug]/page.tsx "/blog/my-post" [...slug], [[...slug]] also exist
_components/Card.tsx underscore: private, never routable
params is a promise you must await — a plain object in version 14 and earlier, still readable synchronously in 15 while deprecated.
Rule: a folder is a URL segment unless it is a group or private, and it is a page only when it holds page or route.
Where the code runs: the 'use client' boundary
~2 min
Layouts and pages are Server Components by default. 'use client' does not mark one component as interactive — it marks where the browser bundle begins, and every module that file imports is pulled across with it. Props crossing the line must be serializable, and anything you pass is published.
The reflex when a leaf needs state is to put 'use client' at the top of the tree containing it, which drags every import beneath it into the browser bundle. Mark the smallest interactive component instead and hand server work in as children: props are not part of the client module graph, so a Server Component passed that way still renders on the server.
// app/page.tsx — still a Server Component
<Modal> {/* 'use client': owns the open/closed state */}
<Cart /> {/* runs on the server, arrives rendered */}
</Modal>
Getting the side wrong is a build error, not a runtime surprise: importing useState into a Server Component fails with This API is only available in Client Components. React context is unavailable there too, so a provider must itself be a Client Component taking children, rendered as deep as it goes.
The boundary is also the security line. Only variables prefixed NEXT_PUBLIC_ reach the browser, unprefixed ones become an empty string, and import 'server-only' turns a wrong-side import into a build failure. Props are the leak that survives all of it: passing a whole database row to a client component ships every column, whether the markup shows it or not. Return what the interface renders and nothing else.
Rule: mark the smallest interactive leaf, pass server work in as children, and treat every prop crossing the boundary as published.
Static and dynamic are a spectrum, not a switch
~2 min
Prerendering happens at build time or during revalidation and its result is cached; dynamic rendering happens per request. Next.js draws that line per component, not per route, so one page can serve a static shell instantly and stream the request-dependent parts behind Suspense.
Most frameworks make you choose per route. Here one page can hold a cached function, a prerendered shell and a per-request section at once, and the build prints what each got:
○ / static — no request needed
● /blog/[slug] prerendered from generateStaticParams
◐ /account static shell plus streamed dynamic content
ƒ /dyn rendered per request — this one reads cookies
Anything needing the incoming request — cookies, headers, search parameters — pushes the component reading it out of the prerender. The lever is not a route setting but where the Suspense boundary sits: content outside ships immediately, content inside streams in when it resolves.
The trap is assuming a loading file covers the whole segment. It wraps the page, not the layout above it, so a layout reading request data blocks navigation instead of falling back to the loading UI beside it — no skeleton appears. Wrap that access in its own Suspense boundary, or move the fetch into the page.
Streaming is load-bearing rather than decorative, and so an infrastructure requirement: a reverse proxy or load balancer that buffers responses collapses shell and dynamic parts into one blocking reply, and the shell arrives no earlier than the slowest query.
Rule: put the Suspense boundary around the thing that needs the request, not around the route.
Fetching on the server, and the waterfall you wrote by accident
~2 min
A Server Component can be async and await data directly — no client round trip, no loading state, credentials never leaving the server. The usual defect is sequencing: two awaits in one component run one after the other even when neither needs the other's result.
Because the component is an ordinary async function, the ordinary promise rules apply — and so does the ordinary mistake. Each await suspends the function, so two in a row serialize requests that could have overlapped. Start both, then await together.
// sequential: getAlbums cannot start until getArtist resolves
const artist = await getArtist(name)
const albums = await getAlbums(name)
// parallel: both in flight before the first await
const artistData = getArtist(name)
const albumsData = getAlbums(name)
const [artist, albums] = await Promise.all([artistData, albumsData])
Promise.all rejects as a whole if one request fails; use Promise.allSettled when a partial page beats none.
When a request genuinely depends on an earlier one, the fix is not to flatten it but to stop it blocking everything else: put the dependent component behind Suspense so the rest streams meanwhile.
Two deduplication mechanisms spare you from prop-drilling data just to avoid refetching it: identical fetch calls in one render tree are memoized, and wrapping a database call in React's cache runs it once per request however many components call it — a scope of one request, never shared between users.
An uncached fetch blocks the page until it resolves, which is what Suspense is for.
Rule: start every independent request before you await any of them, and let a real data dependency be the only thing that costs a second round trip.
Caching: find out which model you are on first
~2 min
The stale page is rarely the cache you configured — it is a layer you did not know was on, and development never shows it. Version 16 added Cache Components (use cache) beside the older fetch-options model, so undated advice often describes the other one.
The misconception behind almost every stale page is that a cache you did not configure is not running. Development hides it: pages there are always rendered on demand and never cached, so you see every edit instantly. That is why right locally, stale in production is the normal shape of the report rather than a mystery.
So establish which model you are on. use cache arrived experimentally in 15.0.0 and became Cache Components in 16.0.0; without cacheComponents: true it fails the build naming the flag, leaving you on the older model — cache: 'force-cache' or next: { revalidate } on a fetch, unstable_cache around other work, dynamic or revalidate exported from a segment. Defaults move between majors too — GET route handlers were cached until 15.0.0-RC and dynamic after — so undated advice usually describes a different version than yours. As of 16.2.12 a fetch is not cached between requests unless asked.
The second mistake is reaching for the blunt instrument: dynamic = 'force-dynamic' turns the whole route dynamic and discards everything else's prerendering, when the fix was to opt out at the one request needing fresh data. And a route inherits the shortest revalidation of any layout or page inside it, so one child silently governs the lot.
Under Cache Components, request data cannot be read in a cached scope at all: cookies() there fails with Accessing Dynamic data sources inside a cache scope is not supported. Read outside, pass the value in.
async function greeting(theme: string) {
'use cache' // key includes "dark" or "light"
return expensiveRender(theme)
}
const theme = (await cookies()).get('theme')?.value ?? 'light'
Invalidation splits visibly: updateTag expires immediately so the person who made the change sees it, while revalidateTag serves the stale value and refreshes behind the scenes.
Rule: a cached function must be a pure function of its arguments — read the request outside it and pass in only what it needs.
Mutations: a Server Action is a public endpoint
~2 min
'use server' does not hide a function: it replaces the body in the browser bundle with an identifier that posts back, so anyone able to send that request reaches it. Rendering a form only for signed-in users protects the form, not the action behind it.
The framework does supply real protections: action identifiers are encrypted and non-deterministic, unused actions are stripped from the build so they get no endpoint, the origin is checked against the host, bodies capped at one megabyte. None of it answers may this caller do this.
So every action re-checks for itself. Authenticate, then authorize the resource — being logged in is not permission to modify someone else's row. Treat arguments as untrusted: take an identifier and re-read the record under the session, never an object the client assembled.
'use server'
export async function completeItem(itemId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
// an id from the client is a request, not a claim
const item = await db.item.findFirst({ where: { id: itemId, ownerId: session.user.id } })
if (!item) throw new Error('Forbidden')
await db.item.update({ where: { id: item.id }, data: { completed: true } })
}
Two behaviors surprise people. The response carries the return value and a freshly rendered payload for the route whenever the action redirects, sets a cookie or invalidates immediately, so the interface updates in the same round trip. And redirect throws to unwind, so nothing after it runs: revalidate first.
Expected failures are not exceptions: return them as values and read them with useActionState, saving throws for what a boundary catches.
Rule: check the caller inside every action — the form is user interface, the action is an endpoint.
Route Handlers and Proxy: the request layer
~2 min
route.ts exports one function per HTTP verb and works in Web Request and Response objects. proxy.ts — renamed from middleware in version 16 — runs before routing and belongs to rewrites, redirects and headers, not to deciding who is allowed to do something.
A route file exports GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS; omit OPTIONS and it is implemented for you with the right Allow header. The request is a NextRequest, adding parsed cookies and URL to the standard object.
Proxy is the one most often misused, and the misuse is putting authorization in it. Server functions are not separate routes — they are posts to the route that uses them — so a matcher excluding a path skips every action on it, and moving an action elsewhere silently removes the check. The framework leans the other way where it can: _next/data requests run proxy even when a matcher excludes them, so protecting a page cannot leave its data unprotected.
The matcher is not optional in practice: without one, proxy runs on every request, including static files, optimized images and public assets — so redirect logic there can block your own stylesheets.
export const config = {
// without this, proxy also runs for /_next/static, /_next/image and /public
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
As of version 16 proxy runs on the Node.js runtime and setting a runtime option throws.
Rule: use Proxy to reshape a request, never to decide who may make one — check that where the work happens.
Navigation: what a link prefetches, and why a click still feels slow
~2 min
<Link> prefetches when it is hovered or enters the viewport, but how much arrives depends on the target: a static route is fetched whole, a dynamic one is skipped — unless it has a loading file, which makes its shell and skeleton prefetchable.
Client-side transitions are what make a server-rendered app feel client-rendered: shared layouts stay mounted and interactive, and a transition in progress is interruptible. But it is only instant if something was fetched in advance, and for dynamic routes the default is nothing.
Three causes of a slow-feeling click, in the order worth checking:
A dynamic route with no loading file has nothing to prefetch, so the click waits on a full server render with no acknowledgment. Adding the file is the whole fix.
A dynamic segment that could have been prerendered but lacks generateStaticParams falls back to per-request rendering. Supplying the known values moves it to build time — visible in the build output as the route flipping to prerendered.
On a slow network the prefetch may not have finished, so even the skeleton is missing. useLinkStatus exposes a pending flag; delay showing it by a hundred milliseconds or fast navigations flash it pointlessly.
One more, easy to miss: <Link> is itself a Client Component and cannot prefetch until it hydrates, so a heavy bundle delays every prefetch on the page.
Rule: give every dynamic route a loading file — without one there is nothing for a link to fetch early and nothing to show on click.
Errors: what a boundary catches, and what it never sees
~2 min
An error file is a client-side React error boundary for the segment below it. It catches what its children throw while rendering, and nothing else — a failure inside a click handler passes straight through and the interface carries on as though nothing happened.
Because it is a real React boundary, its scope is the render pass. Event handlers run after rendering, so they fall outside it, as does most async work started from one. The absence is silent: no fallback, no warning, just a button that did nothing.
'use client'
const [error, setError] = useState<Error | null>(null)
const onClick = () => {
try { risky() } catch (reason) { setError(reason as Error) } // no boundary sees this
}
One exception is worth memorizing: something thrown inside startTransition from useTransition does reach the nearest boundary.
An error file must carry 'use client' and receives the error plus a retry function that re-renders the segment. Errors bubble to the nearest boundary above, making placement a product decision: one beside a widget degrades a panel, one at the root blanks the page. The root case has its own file, global-error, which replaces the root layout while active and so renders its own <html> and <body>.
Not everything that goes wrong is an exception. A failed validation or missing record is expected: return it as a value and render it, or call notFound() and let the matching not-found file take over. Reserve throwing for what you cannot render through.
Rule: boundaries protect the render path — an expected failure is a return value, and a handler failure is your own try/catch.
The output layer: metadata, images and CSS
~2 min
metadata and generateMetadata are Server Component exports only. The image component wants each image's intrinsic width and height so the browser can reserve space before the bytes arrive — an aspect ratio, not a rendered size, which stays with CSS.
Export a static metadata object for fixed tags, or generateMetadata when the title depends on data; if the page fetches the same record, wrap it in React's cache so the two do not request it twice. On dynamically rendered pages metadata streams rather than blocking visible content — turned off for crawlers, identified by user agent, which expect it in the markup.
Several outputs are file conventions rather than code — favicon.ico, opengraph-image, robots.txt, sitemap.xml — each overridable deeper in the tree so a section carries its own social image. Generating one in code uses ImageResponse — flexbox and a subset of CSS only, no grid.
Images are where the layout-shift mistake lives. A static import carries its dimensions and the component fills them in; a remote or runtime URL cannot, so you supply them — and remote hosts must be listed in remotePatterns, the older domains option deprecated since version 14.
import hero from './hero.png'
<Image src={hero} alt="Reading room" /> // size from the file
<Image src={remoteUrl} alt="Cover" width={1200} height={630} /> // you supply it
For CSS, modules scope class names locally and are the safe default; global stylesheets are never unloaded as you navigate, so keep them genuinely global. Import order sets cascade order and can differ in development — verify against a production build.
Rule: reserve the space before the bytes arrive and resolve the document head on the server — neither is something the browser can correct afterwards.
Build, environment, and what only breaks in production
~2 min
A NEXT_PUBLIC_ variable is inlined into the browser bundle at build time, so one artifact cannot carry different public values per environment. Server-side variables read during dynamic rendering really are read per request — a distinction that decides what one build can be promoted through.
Build once with a public variable set and its value is findable in the shipped JavaScript; an unprefixed one is nowhere in it. Server-side, process.env is read per request as long as the component renders dynamically — await connection() opts in — which is what lets one image serve staging and production.
const brand = process.env.NEXT_PUBLIC_BRAND // frozen at build
await connection()
const key = process.env.API_KEY // read per request
Running more than one instance introduces three agreements they must share, each failing in a way that looks like a bug elsewhere. Variables captured by an inline action are encrypted with a per-build key, so instances need a common NEXT_SERVER_ACTIONS_ENCRYPTION_KEY or a client's action identifier stops resolving. A configured deploymentId gets a mid-deploy client detected and sent through a full reload rather than a broken transition. And the page cache defaults to each instance's own local disk, so invalidating a tag on one leaves the others stale until they share a cache handler.
Two smaller ones. The config file is an ordinary Node module, never processed by the bundler, so it must run on your Node version. And async Server Components are not yet fully supported by unit-test runners — cover them end-to-end.
Rule: anything prefixed NEXT_PUBLIC_ is frozen at build, and anything two instances must agree on has to be configured rather than generated.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.