Next.js from the Inside
Part 1. Next.js Architecture.
Introduction
Next.js is one of the most popular frameworks in the React ecosystem, and this year it turned 10 years old. Starting out as a simple server-side rendering solution, it has grown into a full-fledged platform with its own compiler, bundler, two server runtimes, and its own protocol.
In this series we won't be looking at how to build an application with Next.js — instead, we'll figure out how it works under the hood. Why: to understand why things work the way they do, to know where to look in the source code when the documentation gives no answer, and to see the architectural decisions and trade-offs behind every abstraction. In the following articles we will examine each layer, starting with a high-level overview.
A Brief History
Next.js appeared in October 2016 as an answer to a specific problem: React had no full-fledged Server Side Rendering out of the box. Every new project required implementing its own custom SSR: manually configuring Webpack, writing server code, dealing with hydration. Next.js hid all of that behind simple conventions — file-based routing via pages/, a single getInitialProps function for fetching data on both the server and the client, and automatic builds.
At that stage the architecture was minimal: a thin layer between React and a Node.js HTTP server. The server rendered components into HTML, and the client hydrated them. Compilation was done the standard way with Webpack and Babel.
Starting with version 9.3, the monolithic getInitialProps gave way to separate getStaticProps / getServerSideProps. Version 12 replaced Babel with SWC, which made it possible to speed up compilation by dozens of times. And version 13 brought a turning point — App Router, React Server Components, Streaming. In effect, a second architecture emerged inside Next.js.
Today Turbopack is the default bundler, caching has been reworked from scratch, and Partial Prerendering combines static and dynamic content on a single page. Next.js has transformed from a thin wrapper over React into a full-fledged server platform with its own Rust bundler, two runtime environments, and its own protocol for transferring data between server and client.
Compilation and Build
Before talking about rendering, let's figure out what happens to code before it reaches the server or the browser.
SWC — the Compiler
SWC (Speedy Web Compiler) is a compiler written in Rust that handles the transformation of individual files. It strips TypeScript types, turns JSX into calls to the React runtime, and performs minification. In the Next.js repository, SWC lives as a native module next-swc (the packages/next-swc/ directory), which integrates with Node.js through N-API — a mechanism for native add-ons that allows calling Rust code directly from JavaScript without intermediate processes.
SWC is a transformer of individual files. It knows nothing about the dependency graph between modules, does no bundling, and makes no decisions about which code ends up on the client and which stays on the server. Those tasks are the responsibility of the bundler.
Turbopack — the Bundler
Turbopack is also written in Rust, but it solves a different problem. Where SWC works with one file at a time, Turbopack sees the whole application and builds a dependency graph out of it.
At the core of Turbopack lies a system of memoization and incremental computation. Instead of rebuilding everything on every change, Turbopack models the build as a graph. Every file and every transformation is a node of the graph; the edges are dependencies. When a file changes, only the affected subgraph is invalidated, not the entire application.
In dev mode Turbopack uses lazy bundling: only what the browser requested gets built. If a user opened /dashboard, the /settings and /profile routes are not compiled at all — they will be built when (and if) they are visited. Another architectural decision is the unified graph: a single dependency graph for all target environments (client, server). Webpack used separate compilers for each environment and then stitched the results together — Turbopack does it within a single graph.
Turbopack's code lives in the turbopack/crates/ directory of the Next.js monorepo. SWC, meanwhile, is a tool that Turbopack uses to transform individual files.
The Server Layer
Once the application is built and running, incoming requests are handled by the server layer. Its architecture is built around the abstract class BaseServer (the file packages/next/src/server/base-server.ts), which contains the common request-handling logic: URL parsing, route matching against the manifest, cache management, making decisions about rendering.
Two implementations inherit from BaseServer. NextNodeServer — for running on a full Node.js (standard next start or a self-hosted server). It has access to all Node.js APIs: fs, crypto, native modules, working with databases through persistent connections. NextWebServer — for Edge Runtime, a lightweight environment based on Web APIs (Request/Response). Edge Runtime is limited: no filesystem, no native modules, a code size limit of 1–4 MB, no persistent connections. But on the Vercel platform Edge Runtime is distributed globally, providing minimal latency. With self-hosting, Edge Runtime still works, but without global distribution its main advantage — low latency — is largely lost.
Middleware always runs on Edge Runtime — this is code that intercepts a request before routing and can redirect, rewrite URLs, check authorization. Server Components, Route Handlers, and Server Actions use the Node.js Runtime by default, but can be switched to Edge via export const runtime = 'edge'.
During the build, Next.js analyzes the routes directory and creates a manifest — a data structure describing which routes exist and which modules correspond to them. For every incoming request, BaseServer uses this manifest to find the needed route module and hands control over to the rendering layer.
Pages Router
Pages Router is the first rendering architecture of Next.js, based on the pages/ directory. Even though App Router is positioned as its replacement, Pages Router is still supported, actively used in production, and not planned for removal. Many large projects still run on it, and the two routers can coexist in one application (though they do not interact with each other).
A Page as the Unit of Rendering
In Pages Router, the unit of rendering is the page. The file pages/about.tsx is the route /about. The file pages/posts/[id].tsx is the dynamic route /posts/:id. Each page is a React component exported by default, plus optionally one of the data-fetching functions.
Two special files define the skeleton of the application. _app.tsx is a wrapper around all pages: this is where global providers live (theme, authorization, state manager), layouts, and shared logic. When navigating between pages, _app persists and only the inner page component changes. _document.tsx is customization of the server HTML: here you can add meta tags to <head>, change <html> and <body>. This file is rendered only on the server and has no access to client APIs.
Data Fetching Strategies
In Pages Router, data fetching is tightly bound to the page level — you cannot fetch data in a separate component inside the tree. There are three strategies, each with a strictly defined moment of execution.
getStaticProps runs at build time (next build). Its result is data saved alongside the prerendered HTML. On navigation, Next.js loads these data (rather than re-requesting them from the server) and passes them to the page component. This is the fastest option — essentially, a static site. ISR (Incremental Static Regeneration) adds the ability to regenerate in the background through the revalidate parameter: the page is served from the cache while a rebuild with updated data runs in the background.
getServerSideProps runs on every request, strictly on the server. Even during client-side navigation, Next.js makes a request to the server and executes the function. Unlike getInitialProps, this code is guaranteed never to end up in the client bundle, so you can safely use server-side secrets, direct database access, and other server APIs.
getStaticPaths works in tandem with getStaticProps for dynamic routes. It defines which specific paths should be prerendered at build time — for a blog, that would be the list of IDs of all posts. The fallback parameter controls the behavior for paths that were not prerendered: false — return 404, true — show a loading state and generate the page in the background, 'blocking' — wait for generation and serve the finished page.
One more important detail — Automatic Static Optimization. If a page does not export either getServerSideProps or getInitialProps, Next.js automatically generates it as static at build time. This means that a simple page without data fetching is served as static HTML without any server work.
Rendering and Hydration
On the first visit to a page with SSR, the server executes getServerSideProps (or uses the cached result of getStaticProps), passes the data as props to the React component, and renders the entire tree into HTML via renderToReadableStream. The browser receives the finished HTML, displays it, then loads the JavaScript bundle and hydrates — attaches event handlers, restores state, makes the page interactive.
The key point: in Pages Router, the whole page is hydrated as a single unit. Even if 90% of the content is static text, React must walk the entire tree of components on the client to attach handlers and make sure the server HTML matches the client render. All the JavaScript of all components of the page ends up in the client bundle. This is one of the fundamental problems that App Router solves with Server Components.
During client-side navigation (a transition via <Link> or router.push()), the page does not reload. Instead, Next.js requests the data from the server (for getServerSideProps) or takes the prerendered data (for getStaticProps), loads the JavaScript bundle of the new page, and renders it on the client. _app persists in this case — only the inner page component updates.
Limitations of Pages Router
Pages Router works well for many scenarios, but it has architectural limitations, and these are exactly what motivated the creation of App Router. Data fetching is bound to the page level — you cannot fetch data in a layout or in a separate component without a client-side fetch. Layouts are not a built-in concept — _app is a single wrapper for the whole application, and nested layouts require writing wrappers by hand. All the JavaScript of all components of the page ends up in the client bundle because there is no mechanism for separating server and client components.
App Router: a New Architecture
App Router, introduced in Next.js 13 and stable since 13.4, is not an update to Pages Router but a parallel architecture with a different rendering model. It is built on React Server Components — a feature that became stable in React 19.
A Component as the Unit of Rendering
If in Pages Router the unit of rendering was the page, then in App Router it is the individual component. Each component can be a server component (by default) or a client component (marked with "use client"), and this determines where it executes, whether its code ends up in the client bundle, and whether it can use state and browser APIs.
Routes are described through nested directories in app/, but now each route segment is not just a page but a set of convention files: page.tsx (content), layout.tsx (a skeleton that preserves state across navigations), loading.tsx (loading UI automatically wrapped in <Suspense>), error.tsx (an error boundary), not-found.tsx. Layouts nest inside one another: the root layout wraps the section layout, which wraps the subsection layout — and each of them preserves its state when navigating between child routes.
Two React Runtimes and the Flight Protocol
When a request arrives at App Router, Next.js loads not one but two different React runtimes. The first — for RSC rendering — can execute async components, serialize the result into a special binary format, and insert placeholders for client components. The second — for SSR — takes the result of RSC rendering and turns it into HTML that can be sent to the browser right away.
The key function in the rendering pipeline is renderToHTMLOrFlight() in packages/next/src/server/app-render/. It decides the format of the response based on the request headers. If the browser is loading a page for the first time (a regular GET), the server serves HTML with inline insertions of the RSC Payload. If it is client-side navigation (the request contains the header RSC: 1), the server returns only the RSC Payload — a compact binary structure that the client-side React uses to update the DOM without reloading the page.
The RSC Payload is the heart of the protocol that the community informally calls Flight. Inside it are the rendered content of server components (already ready for insertion into the DOM), references to modules of client components (pointers of the form "module X with props Y should go here"), props passed from server components to client ones, and the structure of Suspense boundaries. On the client, React deserializes this stream, recursively restores the tree of components, and replaces the module references with actual client components, loading their JavaScript as needed.
Streaming and Selective Hydration
Unlike Pages Router, where the server rendered the entire page before sending it, App Router uses streaming via ReadableStream. Each <Suspense> boundary is a potential split point of the stream. The server renders everything it can up to the first Suspense boundary — this is the skeleton of the page. It is sent to the client, and the user already sees content. As async operations complete (data fetching, computations), the server sends the remaining chunks, each of which is a <script> tag that React on the client stitches into the right place in the DOM.
Hydration also works fundamentally differently. Server components are not hydrated at all — they remain static HTML, and their code never ends up in the client bundle. Only client components (marked with "use client") are hydrated — those that need event handlers attached and state initialized. If 80% of the content on a page consists of server components, then 80% of the JavaScript simply never gets sent to the browser.
The Client Router
On the client side, App Router has its own AppRouter (packages/next/src/client/components/app-router.tsx), and it works nothing like the Pages Router router. When navigating via <Link> or useRouter(), the router sends a request with the header RSC: 1, receives the RSC Payload (not HTML), and passes it to React, which updates the DOM. Layouts that did not change preserve their state — this means, for example, that the scroll position in a side menu or open accordions are not reset when moving between nested routes.
The router also manages the client-side cache of RSC Payloads. When hovering over a <Link>, a prefetch happens — the payload is loaded in advance, making the subsequent navigation instant.
Caching
The caching system is perhaps the most confusing part of the Next.js architecture, having gone through several radical rethinks. In Next.js 14, caching worked implicitly: fetch requests were cached by default, and to get fresh data you had to explicitly specify { cache: 'no-store' }. This led to hard-to-debug bugs.
In Next.js 16 with Cache Components enabled, the paradigm flipped: by default, nothing is cached. To enable the cache, the developer explicitly uses the "use cache" directive at the level of a component or function, and cacheLife() sets the lifetime.
Partial Prerendering
PPR (Partial Prerendering) is the logical continuation of the streaming idea. Instead of choosing between "a page is either static or dynamic," PPR allows combining both approaches on one page. At build time, Next.js renders everything it can into static HTML that is served instantly from a CDN. Dynamic parts (for example, personalized content behind <Suspense>) are streamed at runtime, filling in the "holes" in the HTML as they become ready. Thus, a marketing page with a single "Hello, %username%" widget is served in milliseconds from static content, while personalization loads right after.
Server Actions
Server Actions are a mechanism for calling server functions from client code without writing API routes. A function with the "use server" directive receives a unique action ID at compile time. On the client, calling this function is replaced with a POST request with serialized arguments. On the server, Next.js finds the needed function by the action ID and executes it, after which it can trigger cache invalidation and return an updated RSC Payload. Forms with Server Actions work even without JavaScript in the browser.
Conclusion
Next.js is not a framework in the usual sense, but rather a platform that combines a compiler written in Rust, an incremental bundler, two server runtimes, its own protocol for transferring data between server and client, a multi-level caching system, and a client router running on top of all of that. The complexity of the system reflects the complexity of the task it solves — giving the developer tools for building fast, SEO-friendly applications with a minimal amount of JavaScript in the browser. But for all this power you pay with a high entry barrier and the need to understand what happens under the hood.
In the following articles of the series, we will dive into each of the layers covered here: from the inner workings of the Flight Protocol to the mechanics of Server Actions.
Part 2. How Pages Router Works.
Introduction
In the first part we looked at the architecture of Next.js from above — what layers it consists of and how they are connected. Now we will trace how Pages Router handles a page from start to finish: what next build creates, how the server renders HTML, how the browser brings it to life through hydration, and what happens when a user clicks a link.
Production build
Before talking about rendering, we need to understand what actually appears after a production build. When you run next build, Next.js walks through the pages/ directory, compiles each file and creates a set of artifacts in the .next/ directory. These artifacts are not only JavaScript but also a system of manifests that describes how the application is structured and how it should be served.
Manifests
After the build, several JSON files appear in .next/ that serve as service maps for the server and the client.
.next/server/pages-manifest.json — a mapping of routes to server modules. The key is a URL pattern, the value is a path to the compiled server file. When a request arrives at the server, Next.js looks up the needed module precisely through this manifest.
{
"/": "pages/index.html",
"/_app": "pages/_app.js",
"/_document": "pages/_document.js",
"/_error": "pages/_error.js",
"/api/hello": "pages/api/hello.js",
"/posts/[id]": "pages/posts/[id].json",
"/404": "pages/404.html",
...
}
.next/build-manifest.json — a mapping of routes to client JavaScript chunks. For each page it lists which JS files need to be loaded in the browser for the page to become interactive. This includes shared chunks (React, framework code) as well as chunks specific to a particular page. When the server generates HTML, it uses this manifest to insert the correct <script> tags — this is exactly what the <NextScript /> component in _document is responsible for.
{
"pages": {
"/": [
"static/chunks/0yk2rv91-thjg.js",
"static/chunks/0-ujd6kb7x1xa.js",
"static/chunks/0cdz4mh8oziua.js",
"static/chunks/0apqosxrma2cd.css",
"static/chunks/turbopack-0q2p_rmwhdtnh.js"
],
"/_app": [
"static/chunks/15bye611qo.h4.js",
"static/chunks/0-ujd6kb7x1xa.js",
"static/chunks/0cdz4mh8oziua.js",
"static/chunks/05~30wuay1au-.css",
"static/chunks/turbopack-0i~wcygbrw9og.js"
],
"/posts/[id]": [
"static/chunks/14zh8xwet8_if.js",
"static/chunks/0-ujd6kb7x1xa.js",
"static/chunks/0cdz4mh8oziua.js",
"static/chunks/17zyc9w4lgxk2.css",
"static/chunks/turbopack-014.yrp7tenhs.js"
],
...
},
"lowPriorityFiles": [
"static/Cvdy_qwv7xXS5CV3eexd8/_buildManifest.js",
"static/Cvdy_qwv7xXS5CV3eexd8/_ssgManifest.js",
"static/Cvdy_qwv7xXS5CV3eexd8/_clientMiddlewareManifest.js"
],
...
}
.next/prerender-manifest.json — a description of statically generated pages. For each page using getStaticProps, it lists parameters here: whether it was prerendered at build time, the value of initialRevalidateSeconds and initialExpireSeconds (for ISR), and the fallback strategy for dynamic routes. The server consults this manifest to decide whether it can serve cached HTML or needs to trigger regeneration.
{
"version": 4,
"routes": {
"/isr": {
"initialRevalidateSeconds": 15,
"initialExpireSeconds": 31536000,
"srcRoute": null,
"dataRoute": "/_next/data/Cvdy_qwv7xXS5CV3eexd8/isr.json",
"allowHeader": [
"host",
"x-matched-path",
"x-prerender-revalidate",
"x-prerender-revalidate-if-generated",
"x-next-revalidated-tags",
"x-next-revalidate-tag-token"
]
},
"/posts/1": {
"initialRevalidateSeconds": false,
"srcRoute": "/posts/[id]",
"dataRoute": "/_next/data/Cvdy_qwv7xXS5CV3eexd8/posts/1.json",
"allowHeader": [
...
]
},
"/posts/2": {
...
},
"/posts/3": {
...
}
},
"dynamicRoutes": {
"/posts/[id]": {
"routeRegex": "^/posts/([^/]+?)(?:/)?$",
"dataRoute": "/_next/data/Cvdy_qwv7xXS5CV3eexd8/posts/[id].json",
"fallback": false,
"dataRouteRegex": "^/_next/data/Cvdy_qwv7xXS5CV3eexd8/posts/([^/]+?)\\\\.json$",
"allowHeader": [
...
]
}
},
...
}
.next/routes-manifest.json — a complete map of the application's routes: static ones, dynamic ones, their matching priority, plus rewrites, redirects and headers from next.config.js.
{
"version": 3,
"appType": "pages",
"redirects": [
{
"source": "/:path+/",
"destination": "/:path+",
"internal": true,
"priority": true,
"statusCode": 308,
"regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$"
}
],
...
"dynamicRoutes": [
{
"page": "/posts/[id]",
"regex": "^/posts/([^/]+?)(?:/)?$",
"routeKeys": {
"nxtPid": "nxtPid"
},
"namedRegex": "^/posts/(?<nxtPid>[^/]+?)(?:/)?$"
}
],
"staticRoutes": [
{
"page": "/",
"regex": "^/(?:/)?$",
"routeKeys": {},
"namedRegex": "^/(?:/)?$"
},
{
"page": "/api/hello",
"regex": "^/api/hello(?:/)?$",
"routeKeys": {},
"namedRegex": "^/api/hello(?:/)?$"
},
...
],
"dataRoutes": [
{
"page": "/about",
"dataRouteRegex": "^/_next/data/6ENUwtsxmX3loz\\\\-Fz6Ybh/about\\\\.json$"
},
...
],
"rsc": {
"header": "rsc",
...
},
...
}
Server and client files
Besides the manifests, the build creates two groups of files. In .next/server/pages/ lie the server modules — compiled versions of your pages, intended for execution on Node.js. For pages with getStaticProps, prerendered HTML files and JSON data files also lie there. For example, for pages/about.tsx without data-fetching functions an about.html will appear — the result of Automatic Static Optimization; and for pages/posts/[id].tsx with getStaticProps and getStaticPaths — a set of posts/1.html, posts/1.json, posts/2.html, posts/2.json and so on, one per path defined in getStaticPaths.
In .next/static/chunks/ lie the client JS bundles. Next.js automatically splits the code into chunks: common framework code (React, Next.js itself), common modules used by multiple pages, and a separate chunk for each page. This is route-based code splitting — when loading the /about page, the browser does not download the JavaScript for /posts/[id].
How the rendering strategy is determined
At build time, Next.js analyzes which functions each page exports and, based on that, decides how it will be served at runtime.
If a page exports getStaticProps — this is Static Generation. The page is rendered into HTML at build time, and the result is saved as a file. At runtime the server serves the ready-made HTML without any computation. If the revalidate parameter is specified, ISR is enabled — Incremental Static Regeneration.
If a page exports getServerSideProps — this is Server-Side Rendering. HTML will be generated on every request. Nothing is prerendered at build time.
If a page exports getInitialProps — this is also server-side rendering on the first visit, but with an important difference that we will discuss below.
If a page exports none of these functions — Automatic Static Optimization kicks in. Next.js determines that the page does not need server data and generates it as static HTML at build time. This is the fastest option — essentially a static file. An important nuance: if getInitialProps is defined in _app.tsx, Automatic Static Optimization is disabled for all pages of the application, because Next.js can no longer guarantee that a page does not depend on server data.
This information can be seen in the output of next build: next to each route there is an icon — a circle (static), a lambda (SSR), or an empty circle (ISR with a revalidate interval).
Route (pages) Revalidate Expire
┌ ○ /
├ /_app
├ ○ /404
├ ƒ /api/hello
├ ○ /client-fetch
├ ● /isr 15s 1y
├ ● /posts/[id]
│ ├ /posts/1
│ ├ /posts/2
│ └ /posts/3
├ ● /ssg
└ ƒ /ssr
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand
Server-side rendering
A user types a URL into the address bar and presses Enter. The request arrives at the Next.js server. What happens next depends on the rendering strategy, but the overall pipeline is always the same: fetch the data, render the React tree into HTML, serve the result.
Route matching
The NextNodeServer takes the URL from the request, normalizes it and looks for a match in routes-manifest.json. The matching priority is determined at build time: static routes are checked first (exact match), then dynamic ones ([param]), then catch-all ([...slug]). If the route is found — the corresponding module is loaded from server/pages-manifest.json.
Then the server looks at which data-fetching function the page exports.
getStaticProps
If a page uses getStaticProps, on the first visit the server serves the HTML and JSON generated at build time. No server code is executed — this is reading a file from disk.
If ISR is enabled (the revalidate parameter), the logic becomes more complex. The server works according to the stale-while-revalidate model: it serves the cached version instantly, but if more than revalidate seconds have passed since the last generation, it triggers a background regeneration. The next visitor will receive the already updated version. Thus, no user waits for generation — everyone receives the cached response, and the update happens asynchronously.
For dynamic routes with getStaticPaths, the behavior when requesting an unknown path depends on the fallback parameter. If fallback: false — the server returns 404. If fallback: true — the server serves the page without data (the component may show a skeleton) and starts generation in the background; on the next request to this path, ready-made HTML will be available. If fallback: 'blocking' — the server waits for the generation and serves the finished page, without any intermediate state.
getServerSideProps
This function runs on every request, strictly on the server. Its code is guaranteed not to end up in the client bundle — Next.js strips it from the client chunks at build time. This means that in getServerSideProps it is safe to query the database directly, use server secrets and API keys, read the file system.
The function receives a context object with access to req, res, params, query. The result is a { props } object that will be passed to the page's React component.
getInitialProps
getInitialProps is the original data-fetching function in Next.js, which appeared in the very first versions of the framework. Its key difference from getServerSideProps: it runs both on the server and on the client. On the first visit (a full page load) — on the server. On client-side navigation via <Link> or router.push() — in the browser.
This dual behavior has a serious consequence: the code of getInitialProps ends up in the client bundle. If you use server secrets, direct DB access or imports of server modules like fs there — they will end up in the JavaScript that the browser loads. That is why getServerSideProps is safer — it is guaranteed to run only on the server, and during client-side navigation Next.js calls it through the server endpoint /_next/data/ without executing code in the browser.
Moreover, getInitialProps in _app.tsx disables Automatic Static Optimization for all pages. This happens because Next.js cannot determine at build time what data _app.getInitialProps will return, and is forced to perform server-side rendering for every page.
An important nuance: if getInitialProps is used in _app.tsx, while a specific page uses getServerSideProps, then during client-side navigation to that page _app.getInitialProps will also run on the server rather than on the client. Next.js switches the execution context because it needs to go to the server for getServerSideProps anyway.
HTML rendering
Once the data has been fetched (no matter by which method), rendering of the React tree into HTML begins. The order is as follows:
First, Next.js calls _app.tsx. This is a wrapper around all pages — global providers (theme, authorization, state manager) usually live here. The _app component receives two props: Component (the current page) and pageProps (the result of the data-fetching function). In essence, _app is <Component {...pageProps} /> wrapped in the necessary providers.
Then React renders the whole tree — _app → the page → all child components — into a virtual DOM, and then serializes it into an HTML string. Pages Router uses renderToReadableStream. However, streaming is limited: the data is fetched before rendering starts via getServerSideProps/getStaticProps, React receives already prepared props and renders the complete tree in one pass. Streaming here means delivering the HTML as it is generated, without the ability to send the page skeleton now and send the rest of the content later, as done in AppRouter.
Once the React tree has been rendered, _document.tsx comes into play. This file is responsible for the outer HTML skeleton — what lies outside the React application. The <html>, <head> and <body> tags are defined here. Inside <body> there are two key components: <Main /> — the rendered HTML of the React application is inserted here, and <NextScript /> — Next.js inserts <script> tags with the client JS bundles listed in build-manifest.json for the current page here.
_document is rendered only on the server. You cannot use event handlers or hooks in it. It is not re-rendered during client-side navigation.
__NEXT_DATA__
Before the HTML is sent to the client, Next.js inserts a special tag into it:
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": { "posts": [...] }
},
"page": "/blog",
"query": {},
"buildId": "dQwLxa7HFItvOZwgV08yw",
...
}
</script>
This is serialized JSON containing the result of the data-fetching function (pageProps), the current route (page), the query parameters (query) and the build identifier (buildId). Without these data hydration is impossible — React on the client must receive the very same props that were used during server rendering in order to reconstruct the virtual DOM and verify that it matches the real DOM.
This mechanism has a practical consequence: if getServerSideProps or getStaticProps returns a large amount of data, it will end up in the HTML twice — as rendered markup and as JSON in __NEXT_DATA__. Next.js issues a warning if the size of __NEXT_DATA__ exceeds 128 KB. The recommendation is to return from data-fetching functions only the data needed for the first render; load the rest on the client.
Hydration
The browser has received the HTML, the user sees the page — text, images, layout. But buttons are not clickable yet, forms do not submit, links work like ordinary <a> tags with a full reload. The page is non-interactive because event handlers have not yet been attached to the DOM and the state of the React components has not been initialized.
Hydration is the process in which React on the client "picks up" the server HTML and makes it interactive. Here is how it works.
The browser loads the JS bundles listed in <NextScript />. Among them — React, the Next.js framework code and the chunk of the current page. The Next.js client code reads __NEXT_DATA__ from the DOM, extracts pageProps, page and other parameters. Then it calls hydrateRoot(), passing the page component with the same props that were used on the server.
React on the client renders the virtual DOM from the passed props and compares it with the real DOM that already exists on the page. If everything matches — React simply attaches event handlers to the existing DOM elements without touching the markup. No changes occur in the DOM — React "attaches" itself to it.
If the server HTML and the client virtual DOM diverge — a hydration mismatch occurs. React issues a warning and, in the worst case, re-renders the mismatched part of the tree from scratch, causing interface flickering. Typical causes of a mismatch: using Date.now() or Math.random() during render (the values on the server and the client will differ), accessing window or localStorage without checking the environment, browser extensions that modify the DOM before React loads.
In Pages Router, the entire component tree is hydrated as a whole. There is no mechanism that would allow marking part of the components as "server-only" and excluding them from hydration — this is one of the fundamental differences from App Router, where server components are not hydrated at all. In practice this means that the JavaScript of all components on the page ends up in the client bundle, even if 90% of the content is static text.
One more nuance: with Automatic Static Optimization, the router parameters (query) on the server will be empty, because during prerendering there is no real request with a query string. After hydration, Next.js updates query with the actual values from the URL, which causes an additional re-render. That is why router.isReady exists — a flag indicating that hydration is complete and the route parameters are up to date.
Client-side navigation
Up to this point we have been talking about a full page load — the user entered a URL, received the HTML, hydration happened. Now the user clicks a <Link> or calls router.push(). The page does not reload — instead, the Next.js client router starts working.
Prefetch
Even before the user clicks, Next.js begins preparation. Prefetch in Pages Router works in two stages, and their behavior differs depending on which data-fetching function the target page uses.
The first stage is viewport prefetch. When a <Link> component enters the visible area of the screen, Next.js automatically loads the target page's resources in the background. For pages with getStaticProps, both the JS chunks and the JSON with data are loaded — both resources are static, they are safe to request in advance. For pages with getServerSideProps, only the JS chunks are loaded, without the data — because the data depends on the specific request and will be fetched only at the moment of real navigation. Viewport prefetch is deduplicated: the same URL is not requested again, Next.js keeps the already loaded keys in a Set.
The second stage is hover prefetch. When the user hovers the cursor over a link, Next.js calls router.prefetch() again. This time the check against the Set is skipped and the request goes out every time. For pages with getStaticProps this leads to repeated requests for the JSON on every hover — however many times you moved the mouse over it, that many requests went out. For pages with getServerSideProps, nothing is requested here.
This behavior is a deliberate decision by the Next.js developers. The idea is that hovering signals an intention to click, and at that moment it is worth requesting the freshest data, even if it was loaded earlier. However, side effects arise too — multiple requests when moving the mouse across a list of links. Hover prefetch cannot be disabled: prefetch={false} disables only viewport prefetch, while hover prefetch continues to work. The only way to get rid of it completely is to use a plain <a> tag instead of <Link>, losing client-side navigation in the process.
Data fetching
When navigation happens, the behavior depends on the data-fetching function of the target page.
If the target page uses getServerSideProps, the client router makes a request to /_next/data/{buildId}/page.json. This is a special endpoint that Next.js automatically creates for every page with getServerSideProps. On the server this request is handled like a regular one — getServerSideProps is called, the result is serialized into JSON and sent to the client. No HTML is generated — only data. The response format: { "pageProps": { ... } }.
If the target page uses getStaticProps, the router loads the prerendered JSON file at a path of the form /_next/data/{buildId}/page.json. This file was created at build time (or during ISR regeneration) and is served as a static resource, without executing any server code.
If the target page uses getInitialProps, the behavior is fundamentally different: no request to the server happens. Instead, getInitialProps executes right in the browser. The function's code, loaded as part of the page's JS chunk, is invoked on the client, makes its fetch requests (to the API, to external services) and returns the props.
Rendering the new page
Once the data has been fetched (no matter by which method), the client router passes it to the new page's component. _app is preserved at this point — only the inner component is updated (the Component prop in _app). This means that global providers, the state manager's state and persistent interface elements defined in _app are not reset during navigation. React performs a regular reconciliation — compares the old and new trees and updates the changed parts of the DOM.
At the same time, the URL in the address bar is updated through the History API, and a new entry is added to the browser history. The "Back" button works — when pressed, the router will load the previous page using the same mechanism.
Shallow routing
Pages Router has a shallow routing mode — navigation in which the URL changes but the data-fetching functions are not called. It is useful for updating query parameters without refetching data: for example, during filtering or pagination, when the data is already available on the client. It is invoked via router.push(url, as, { shallow: true }). With shallow routing, getServerSideProps and getStaticProps are not executed, and the page component receives the updated router.query. Shallow routing works only within a single page — when navigating to another route it is ignored.
Summary
Pages Router is an architecturally simple model. Data is fetched at the page level through one of three functions, React renders the full tree into HTML, the props are duplicated in __NEXT_DATA__ for hydration, and during client-side navigation the router requests the data separately from the HTML and renders the page on the client. Every component ends up in the client bundle, every component is hydrated. This limits performance and increases bundle sizes, but provides predictability — it is always clear which code runs where and how data gets into a component.
Part 3. App Router: From Request to Hydration.
Introduction
In this part we will look at the App Router, which appeared in Next.js starting with version 13 and is built on React Server Components.
The App Router is not an add-on over the Pages Router, but a fundamentally different rendering model with its own data structures, its own protocol for transferring data between the server and the client, and its own client-side router.
The Two React Runtimes
The most important thing to keep in mind about the App Router: two different builds of React run in it simultaneously. They are two different sets of modules with different entry points that are loaded into one process.
The first build is the RSC runtime (React Server Components). It can execute server components (including async components with await inside) and serialize the result not into HTML but into a special binary stream. This build cannot work with the DOM and has no useState or useEffect. Its task is to turn the tree of server components into a data stream.
The second build is the SSR/client runtime. This is the ordinary React we know: react-dom/server on the server and react-dom/client in the browser. It can render into HTML and hydrate.
The separation is implemented via the conditional export react-server in the package.json of React itself. If you look inside the copy of React bundled with Next.js, there is a map like this:
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
},...
}
This is the standard module resolution mechanism described in the Node documentation. The exports field defines a specific map. When something imports react, the resolver walks this map: if the react-server condition is active in the current context, the server build react.react-server.js is used (a stripped-down React without useState/useEffect); otherwise the default fires — the ordinary index.js.
Next.js itself then decides for which code the react-server condition is active. It is enabled pointwise for a "layer" in the bundler. Server component modules go into the RSC layer, and for it the resolver is configured with the react-server condition active; client components and the SSR glue live in other layers where this condition is absent. Which layer receives a module is determined by the "use client" boundary; the details of this process belong to the application build process (Webpack/Turbopack).
How a Route Is Described
Before rendering anything, Next.js assembles from the app/ directory a structure that is internally called the loader tree. This is a recursive tree where each node is a route segment and each leaf is a page.
Each node of the loader tree is an array of roughly this shape:
[
segment, // segment name: 'children', '[id]', '__PAGE__', etc.
parallelRoutes, // { [parallelRouteKey]: LoaderTree } — nested segments
modules // { layout?, page?, loading?, error?, 'not-found'?, template?, ... }
]
modules holds lazy references to the modules of convention files: layout.tsx, page.tsx, loading.tsx, error.tsx and so on. And parallelRoutes is an object whose keys correspond to parallel routes. An ordinary nested segment lives under the children key; named slots (for example, @modal, @sidebar) live under their own keys. It is thanks to this structure that the App Router has built-in support for nested layouts and parallel routes.
Rendering
The loader tree is a static description of the route. Rendering turns it into the RSC Payload — a serialized result that will travel to the client.
Let's look at the rendering process with an example.
A segment is a single step of the URL path that corresponds to a single folder in app/. Suppose there is such a project:
app/
layout.tsx // root layout: <html>, header
dashboard/
layout.tsx // sidebar menu
settings/
page.tsx // page "/dashboard/settings"
The URL /dashboard/settings consists of three segments — root → dashboard → settings, — and each has its own folder. Here is what rendering produces for this route:
- The structure of the route — the tree of segment names and their nesting, without content:
root
└─ dashboard
└─ settings
- The content of each segment — what its layout or page rendered into, where each leaves a slot for the next segment:
root → <html>… <Header/> [ dashboard will be nested here ] …</html>
dashboard → <SidebarMenu/> [ settings will be nested here ]
settings → <SettingsForm/>
Nesting the content inside one another following the same hierarchy as in the structure, we get the final page.
Rendering proceeds recursively through the loader tree, segment by segment, from the root downward. With each segment roughly the same thing happens:
- its layout or page is taken and rendered;
- the render descends into the nested segments — and not only the ordinary child, but also all parallel slots, if any;
- around each transition to a child segment a client wrapper component
LayoutRouteris placed — a point inside which the client router can later swap content during navigation without touching the rest of the tree.
The result of each segment is a node that knows its rendered content and references to child nodes. Put together, these nodes form a tree of content.
Here is why structure and content are kept separate. Imagine navigating from /dashboard/settings to /dashboard/billing. The structures of the two routes differ only in the last segment, and the part root → dashboard is shared between them. Then the client compares the lightweight structures, sees that only the tail changed, and it is enough for the server to send new content only for billing. The content of the root and dashboard (together with the state of the sidebar menu and the scroll position) is reused as is. If content and structure were fused into one, the whole tree would have to be resent and re-rendered entirely, as in the Pages Router.
The key moment of the render is what happens at the "use client" boundary. Server React does not execute client components, so upon encountering one of them it does not render it, but inserts a reference to the module into the stream. Which file and which chunk the reference corresponds to, the server takes from page_client-reference-manifest.js — a map that the bundler generates during the build process.
This is how the "server / client" boundary passes exactly along "use client": everything above the boundary has already been rendered into data on the server and will not travel to the browser as code; everything marked "use client" turns into a reference, along which the client will load the necessary JavaScript and bring that piece to life. This is precisely the mechanism thanks to which server component code does not leak into the client bundle.
The result of the render is the RSC Payload: a compact serialized package containing the rendered content of server components, references to client components with their props, and the structure of the route. The format is deliberately economical (its keys are literally single letters), because the package travels over the network and gets inlined into HTML.
Then two things happen with this package in parallel: it is turned into HTML for the first response, and it is inlined into the page so that the client can hydrate the tree.
Flight Protocol
The protocol by which the server and client of the App Router exchange the tree is informally called Flight by the community. In essence, it is the format of those same route structures and segment contents packed so they can be streamed and stitched together piece by piece.
The first is the route structure tree (FlightRouterState). It is still the same lightweight description of "which segments make up the current URL and how they are nested". An important feature: the client and server exchange this structure in both directions. During navigation the client sends the server its own tree, and the server uses it to decide what exactly to send additionally. Besides the shape, the tree carries small annotations with which the client marks individual segments: for example, "rebuild this one from scratch" or "for this one send only the metadata for <head>, no content needed". This way the client controls what the server does with each branch.
The second entity is the content of segments, sliced into "slices". One slice is a compact four-part tuple: segment → how to update the structure at that position → its rendered content → its data for <head>. Content here means the result of the server render: output ready for insertion from server components plus references to client ones. Slicing into slices is needed for streaming: the server sends the skeleton first and delivers the content of individual branches later, as it becomes ready.
How RSC Turns into HTML
During the initial load both runtimes work in tandem, and the order is as follows.
First, the RSC runtime renders the tree and serializes it into a Flight stream. This stream is forked into two identical copies: one goes into HTML generation, the other will be inlined into the page as data.
Then the SSR runtime kicks in — ordinary React that renders into HTML. Here the SSR pass itself is a consumer of the Flight stream. It does not render the tree anew, but takes the first copy of the Flight stream, deserializes it back into React elements, and assembles HTML from them. The same payload is also used to seed the initial state of the client router.
Why so complicated? Because the same Flight stream serves several tasks at once:
- HTML is generated from it for the first display;
- from it the client React restores the tree and understands where the client components that need to be hydrated reside;
- and it is also sent during navigation — already without HTML.
At the same time, in all cases it carries only the result of server components, not their code. A direct pass straight to HTML would not give the client the structure needed to bring the client islands to life.
The Analog of __NEXT_DATA__
In the Pages Router the entire state was packed into a single <script id="__NEXT_DATA__"> tag with JSON inside. In the App Router this would not work, because the data streams. Therefore the Flight stream is cut into chunks, and each chunk is inlined into the page as a separate small <script> that appends data to the global array self.__next_f.
Each entry into this array is marked with a type: initialization, an ordinary text chunk, or a binary piece encoded in base64. The client reads this array and reconstructs the Flight stream from it.
The point of the entire construction: HTML and the data for hydration travel in one stream. The user sees the markup immediately, and the data arrives with the same chunks as the server produces them, so there is no need to wait until the entire state is assembled before starting to send the page.
Hydration
When the browser finally receives the HTML with inline data, the user already sees the page. Now it needs to be brought to life.
The client gathers the chunks from self.__next_f back into the Flight stream and deserializes it into the same tree that existed on the server. The subtlety is that some chunks are already in the array by the time the client code starts (they arrived with the HTML earlier), while others are still on their way — the stream may not have finished. Therefore the client first replays what has accumulated and then intercepts the chunks arriving later. The resulting tree is fed to React for hydration.
At that, server components are not hydrated at all. Only client components are hydrated — those that were inserted into the stream as module references. Following these references, React loads the required JavaScript and attaches handlers and state to it. If 80% of a page consists of server components, then 80% of the code simply never travels to the browser. In the Pages Router instead, the JS of all components always ended up in the bundle.
Summary
Primary rendering in the App Router rests on the division of labor between the two React runtimes and on the intermediate format between them. The RSC runtime executes server components and serializes their result into a Flight stream. The SSR runtime consumes this same stream to produce HTML for the first display. The same stream is inlined into the page through self.__next_f, and from it the client restores the tree and hydrates only the interactive islands — client components — without receiving server code. The loader tree defines the shape of the route, Flight carries its structure and content separately, and the boundaries of LayoutRouter mark out in advance the places where content can later be swapped.
In the next part we will look at how the client navigates between pages without reloading them, how Next.js prefetches the necessary segments through the cache, and how Server Actions close the loop by allowing server functions to be called directly from the client.
Part 4. App Router: Navigation, Cache, and Mutations.
Introduction
In the previous part we traced how an App Router page is born on the server and reaches the browser. In this part we will look at the life of a page after it has loaded. We will examine how client-side navigation works without a page reload, how Next.js pulls in content ahead of time through the multi-level Segment Cache, and how Server Actions let you call server functions directly from the client. All of this relies on the structures described in the previous part.
Client-side navigation
The user clicks on <Link> or calls useRouter().push(). At that moment the page does not reload, because the client-side router takes over.
On navigation the client sends a request to the same URL, but with several service headers (you can see them in the Network tab):
RSC: 1
Next-Router-State-Tree: <the route structure that the client currently has>
Next-Url: <the current URL, needed for interception routes>
RSC: 1 tells the server that what is needed is not HTML but a Flight response.
Next-Router-State-Tree is that very structure (FlightRouterState) describing what the client already has. The value of this header is URL-encoded JSON, so you can read it via JSON.parse(decodeURIComponent(...)).
A cache-busting parameter (?_rsc=<hash>) is also appended to the URL. Many CDNs, having cached HTML under the "bare" URL, may return that same HTML in response to an RSC request. The unique parameter separates HTML and Flight responses into different cache keys.
Having received a request with the submitted structure, the server decides which common layout to start rendering from. It walks simultaneously along its own route tree and along the structure from the client, comparing segment by segment. While segments match, the server descends deeper without rendering anything. As soon as the segments diverge or the client has explicitly marked a branch for updating, the server renders the subtree from that point and returns only that subtree.
The client receives the patch and merges it into its tree. Segments that have not changed keep their nodes, and with them their state: scroll position, open accordions, text entered into forms. This is a direct consequence of a LayoutRouter sitting at every boundary and picking content from the cache by its own slot: the affected slot takes new content, the rest keep the old one.
This is also where the savings come from: when navigating between two pages sharing a common layout, the server will send only the changed part, and the common layout will be neither rendered nor transmitted.
Segment Cache and prefetch
Prefetch means preloading pages behind links: ahead of the click, Next.js pulls in data for pages the user might navigate to, so that navigation itself happens instantly. By default this happens for every <Link> when it enters the viewport, as well as on hover. In the background the router requests the Flight response of the target route and puts it into the cache.
In recent versions navigation and prefetch have been rebuilt around the Segment Cache architecture. Its idea is to split the client-side cache into two levels:
- Route Cache — route structures (segment trees);
- Segment Cache — individual segments with their content.
In the early model prefetch pulled the entire route tree down to the first loading boundary. Now the client first requests only the lightweight route structure, and then fetches the individual segments it is missing. Segments shared by multiple routes (for example, the root layout) are cached once and reused for navigation anywhere.
If you open the Network tab, you can see that hovering over a single link produces not one request but a batch. That is precisely a direct consequence of the per-segment model. First the client fetches the route structure with a separate request (with the header Next-Router-Segment-Prefetch: /_tree), then sends one request for each missing segment, where each one carries the path to that segment in Next-Router-Segment-Prefetch. All prefetch requests are marked with Next-Router-Prefetch: 1 (plus the usual RSC: 1), so they are easy to distinguish from navigational ones in the Network tab.
It might seem that such a number of requests could hurt performance, but that is not the case, and here is why:
- deduplication and caching. Before making a request the client checks the Segment Cache: if the segment is already there (or a request for it is already in flight), it does not request it again.
- reuse of shared segments. The root layout and other shared chunks are downloaded once for the whole application, not again for each link.
- requests are small and parallel. One request — one segment; and over HTTP/2 they are multiplexed over a single connection.
- these are managed tasks. Prefetching is governed by a scheduler with priorities: among other things, it can throttle and cancel stale tasks (for example, when the mouse leaves).
If such behavior is undesirable for a specific link, prefetch can be relaxed or disabled via the prefetch prop of <Link>.
On the server side, content is prepared in advance in a way that allows cutting it into segments, and each segment is annotated with hints — whether it should be requested separately at all, and whether there is a loading boundary beneath it. There is a nuance here regarding which routes support per-segment prefetch: with Cache Components enabled (cacheComponents: true in next.config) — all of them; without them — only fully static pages, because their per-segment responses are prepared during static generation (build or ISR) rather than on the fly.
Server Actions
Server Actions are calls to a server function directly from the client, without manually writing an API route.
At build time every function with the "use server" directive receives a stable identifier — essentially a hash. On the server a map "identifier → actual function" is created, which is stored in the server-reference-manifest files.
On the client, calling such a function turns into a POST request: the identifier travels in the Next-Action header, and the serialized arguments travel in the body. By the identifier, the server finds the required function, decodes the arguments, and executes it.
The response comes back as a Flight stream (Content-Type: text/x-component), and it contains two distinct things:
- the return value of the function — what we wrote in
returninside the action (in the payload this is fielda, from action result); - the updated route tree — the result of re-rendering the page (field
f, from flight).
Here is an important nuance: the re-render happens not always, but only if the action invalidated the cache, for example called revalidatePath() / revalidateTag() or did a redirect(). Then the server understands that the data on the page may have become stale, re-renders the current route, and places the updated Flight next to the return value. The client will apply it with the same mechanism used during navigation, and the UI will immediately show fresh data without a separate request. If the action invalidates nothing, field f arrives empty, and the response contains only the return value.
If the identifier is unknown (for example, the request arrived from an old deployment), the server immediately responds "no such action" and does not proceed with any further processing — neither executing the function nor a possible re-render of the route.
Security of closures deserves separate attention. A server action can be declared inline, right inside a component — and then it can capture variables from the surrounding scope. For example, the pickInline action below uses secret, declared in the component one level up.
// TagPicker.tsx — server component
import { pickTag } from './actions/tag';
export async function TagPicker() {
const secret = await getServerToken(); // a value known only to the server
// secret is captured by the closure of the inline action → it will be encrypted
async function pickInline(tagId: string) {
'use server';
await pickTag(secret, tagId);
}
return (
<>
<form action={pickInline.bind(null, 'hot')}>...</form>
</>
)
}
Herein lies the risk. For the client to later be able to call such an action, the captured variable must imperceptibly travel to the client and back (the client is obligated to send it when calling). And it is easy for something server-related to end up in an action's closure — a token, a key, an internal id.
To keep this from turning into a security hole, such captured variables are encrypted. This happens at build time with a key shared across the whole deployment. The action's identifier is attached to the data as a checksum and verified upon decryption, which protects against tampering. Only the ciphertext settles on the client; it cannot be read or tampered with there — decryption is possible only on the server, which holds the key.
It is important to understand the boundary: only capture through a closure is encrypted. Arguments passed to the action via an explicit .bind(null, …) are not encrypted, so they travel as plain JSON both in the page source and in the body of the POST request. A hidden closure is concealed to catch accidental leakage, but an explicitly passed argument is not.
// TagPicker.tsx — server component
import { pickTag } from './actions/tag';
export async function TagPicker() {
const secret = await getServerToken(); // a value known only to the server
return (
<>
{/* secret passed via explicit .bind → will travel as plaintext */}
<form action={pickTag.bind(null, secret, 'hot')}>...</form>
</>
)
}
Another consequence of the Server Actions architecture: forms using them work even without JavaScript. If an action is bound to <form action={...}>, the browser will send a regular POST, and the server will process it, render, and return the page.
Summary
The life of a page after it loads in App Router is two-way communication between client and server on top of Flight. The price of this whole model is complexity: two runtimes, a custom protocol, a multi-level cache, and non-obvious boundaries between server and client code. But together it delivers what all of this was undertaken for — minimal JavaScript in the browser while preserving SSR and streaming.
Part 5. The Server Layer.
Introduction
In previous parts we covered Pages and the App Router, but all of that discussion already started inside rendering. In fact, before rendering a request passes through an entire layer, which we only briefly mentioned in the first part: route matching, middleware, filesystem checks, serving static files.
In this part we will look at that layer. We will trace a request's path from the moment it arrives on a port to the moment render is invoked. The main thing to keep in mind from the very beginning: next start brings up two logical layers. One is responsible for routing, the second — for rendering.
The HTTP server callback
At its core, Next.js does nothing exotic with networking. When you run next start, the framework creates a plain Node.js HTTP server and attaches a single request handler to it. Everything Next.js knows how to do — routing, rendering, caching — is what happens inside that handler. From the outside it is just a (req, res) function, like in any bare Node.js server.
This also determines how Next.js embeds into someone else's infrastructure: if you already have an HTTP server, Next.js can hand you its handler, and you attach it yourself.
A curious detail of startup: the server starts listening on the port before all the internal machinery is ready. Requests that arrive at that moment are not dropped; they wait until initialization completes and only then are processed. This is done so that the socket opens as quickly as possible and no early request gets lost.
Two layers: router-server and render-server
Now to the main point. Having started the HTTP server, Next.js raises two layers above it with fundamentally different tasks.
The first layer, router-server, is the router. It accepts the raw request and decides what to do with it: apply a redirect from the config, run it through middleware, rewrite the URL by a rewrite rule, serve a static file from disk or, if it is an actual page, pass the request further — to rendering. The router-server itself renders nothing. It knows nothing about React, RSC, or hydration.
The second layer is the render-server. This is the very server we implicitly discussed in previous parts: it takes a resolved route and turns it into HTML or a Flight stream. Inside the render-server live BaseServer and its Node.js implementation, which were covered in the first part.
Next we will look at the router-server in more detail — it is exactly where everything interesting happens before rendering.
The order of request processing
When a request enters the router-server, it is run through a fixed sequence of steps, which can be laid out as this ladder:
- Headers from
next.config— added to the response. - Redirects from the config — if there is a match, the request is turned around.
- Middleware — if the URL matches the matcher, our code runs.
- Rewrites of the
beforeFilesgroup fromnext.config— URL rewriting before filesystem checks. - Filesystem check — an exact match with a real route or a static file.
- Rewrites of the
afterFilesgroup fromnext.config— URL rewriting if nothing was found among files. - Dynamic routes and a re-check.
- Rewrites of the
fallbackgroup fromnext.config— the last chance to rewrite the URL.
This order explains a whole class of questions, for example, why middleware sees a request earlier than a redirect from your code inside a page fires.
In minimal mode — this is how Next.js works on serverless platforms, where routing is handled by the platform itself — almost all of these steps are disabled: redirects, headers, and rewrites have already been applied by the platform's infrastructure. In this mode the router-server mostly just carries the request through to rendering.
What the server serves itself
An important point: not every request reaches the render-server. At the filesystem check step, the router-server compares the path against what it has on disk, and for a whole range of cases answers itself without starting rendering at all.
To know what is on disk, at startup the router-server reads service information about the build: the build identifier, lists of pages and route handlers, the contents of the public folder, rules for middleware. For all of this the server uses the manifests we are already familiar with.
There is a fork depending on the type of match:
- A request to
/_next/static/...— these are chunks assembled at build time. They are served as static files directly, without any involvement from React. - A request to a file from the
publicfolder — it too is served as static content. - A request for an image to be optimized goes into the image optimizer.
- Only a request that matches an actual page or route handler is passed further — into rendering.
API endpoints
So far we have talked about what Next.js does itself. But some routes the developer writes by hand — these are API endpoints. Here it helps to understand how our code plugs into the layer we have just covered. Next.js has two models for writing endpoints, and they are quite different.
Route Handlers — the modern model of the App Router. We create a route.ts file and export from it functions named after HTTP methods: GET, POST, DELETE, and so on. At build time, Next.js wraps this file into a service module that assembles a "method → function" table from our exports. When a request arrives, the module looks at its HTTP method and calls the corresponding handler.
API Routes — the older model of the Pages Router. Here we export a single default handler — a (req, res) function. Next.js runs it through its own wrapper, which sprinkles convenient helpers into req and res: request body parsing, cookie reading, methods like res.json(). This is classic Node style: we receive a Node.js request and response, slightly enriched, and write to the response ourselves.
From the router-server's point of view, an API endpoint and a page are the same type of "output": a dynamic route that must be handed over to the render-server. It is just that the endpoint is rendered not into HTML but into a response that we formed by hand.
Middleware (proxy)
The second point where developer code cuts into the server layer is middleware (in recent versions it is gradually being renamed to proxy). We describe in it a rule — for which paths it should run. At build time this rule turns into a set of regular expressions that go into a service map (middleware-manifest). At runtime, at the third step of the router-server, the request path is checked against these expressions, and if there is a match — our code runs. The rule can be more complex than a simple path match: you can require the presence of a certain header or cookie, or conversely their absence.
Next, our function receives a request and returns a response: "pass it further", "rewrite the URL", "perform a redirect", "add a header". But middleware does not call the router directly. Instead, its decision is encoded into special service headers of the response.
The router-server reads these service headers back out and acts according to them: performs the redirect, substitutes the path, forwards the changed headers to the next step.
Middleware also has an important limitation: it always executes on Edge, not in full-fledged Node.js. The reason lies in its role: middleware stands in the path of all requests matching its rule, even before it becomes clear what should be done with the request at all. On such a hot section, a heavyweight execution environment would severely cut performance.
Handoff to rendering
Suppose a request has passed through the entire ladder: it was not turned around by a redirect, middleware let it through, and by the filesystem it matched an actual page. Now the router-server hands it over to the render-server.
The router-server has already done all the work of resolving the route: it knows the final path and the query parameters. It adds this resolved information as service metadata and calls the render-server's handler. That one takes the ready resolved route and immediately goes into the rendering pipeline — the very one we examined in previous parts.
At the same time, the render-server is not the final authority. The router-server calls it the same way it called any other step of the ladder. And if the render-server returns "this path failed to render" (for example, for a dynamic route without prerendering and without fallback), the router-server does not consider this the end: the last word stays with it, and it simply continues the ladder from the next step, trying to find another output.
Summary
The server layer of Next.js is two layers with a division of labor. The router-server handles routing: runs the request through a fixed ladder of headers, redirects, middleware, rewrites, and filesystem checks; serves static files, assets, and images itself; and carries to rendering only what actually needs to be rendered. The render-server is a separate layer that takes an already resolved route and turns it into a response. Developer code plugs into this pipe in two places: endpoints and middleware.
Part 6. Custom Server.
Introduction
In the previous part, we examined the server layer of Next.js and got to know router-server, which handles routing, and render-server, which is responsible for rendering.
By default, Next.js starts its own server. However, the framework allows you to write a custom server and decide yourself what to do with incoming requests. In this part, we will look at this option in more detail.
Why you might need a custom server
A custom server means we start an HTTP server in the same process as Next.js and pass requests to it ourselves. It is a long-lived Node.js process that we maintain on our own. On platforms where Next.js is deployed as a set of serverless functions with routing handled by the infrastructure (for example, Vercel), such a process does not fit into the model, and we risk losing some of the platform optimizations.
Nevertheless, there remain tasks for which this can be justified. For example, when part of the routes in the same process is served not by Next.js but by a separate Express or Fastify application, a GraphQL endpoint, or a webhook. This also includes a WebSocket server which, for one reason or another, needs to live in the same process and on the same port as Next.js.
All of this is about scenarios where we need to keep Next.js and other server logic in one process on one host. This may be required for a serverful application or if we are limited by the infrastructure. And yet, modern Next.js often offers solutions that do not require your own server.
Minimal server
The canonical custom server looks like this:
import { createServer } from 'http'
import next from 'next'
const port = parseInt(process.env.PORT || '3000', 10)
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer((req, res) => {
handle(req, res)
}).listen(port)
})
Three calls are at work here, and each has its own role.
next(options) creates an application instance. The options object accepts almost the same things that live in next.config.js, as well as dev, dir (the project location), hostname, port, httpServer, and the choice of bundler (turbopack or webpack). The function does not launch anything — it only constructs an object.
app.prepare() initializes the application and returns a promise.
app.getRequestHandler() returns a request handler function with the signature (req, res, parsedUrl?). This is exactly what we attach to our HTTP server. Everything Next.js subsequently does with the request is hidden behind this handle.
The server.js file itself does not go through the Next.js compiler and bundler, unlike pages and components.
The path of a request in handle
What does handle do when we call handle(req, res)?
The point is that handle is the request handler of the router-server. Therefore, the request does not go straight to rendering, but to the beginning of the same staircase we discussed in the previous part: headers from the config, redirects, middleware, rewrites, filesystem checks, serving static files, image optimization — and only then, if needed, rendering through render-server.
Since handle delegates the request to the router-server, and middleware (aka proxy) is one of the steps of the router-server's staircase, the proxy also runs if the path matches its matcher. However, it is important to remember here that it only fires for what reaches handle. If our custom server intercepted a route earlier and handled it itself without ever calling handle, Next.js simply does not see that request, and its proxy will not run for such a route.
Thus, a custom server does not replace the Next.js server but wraps it. We own the socket and the outermost (req, res), but everything that happens with the request afterwards is still the two-layer Next.js server. As a result, the whole process can be written in one line: our HTTP server → handle → router-server → render-server.
Custom routing
Since handle runs the request through all of Next.js routing, where then is our own routing taken into account? For this there is a third argument of handle — parsedUrl.
The correct model of custom routing looks like this: we parse req.url ourselves, change pathname or query if necessary, and pass the modified object to handle as the third argument. Then, if parsedUrl is passed, the handler reassembles req.url from it and only then hands the request over to the router-server. In effect, we edit what the router-server will see as the input URL, and it does all the rest of the work itself.
createServer((req, res) => {
const parsedUrl = parse(req.url, true)
const { pathname } = parsedUrl
if (pathname === '/legacy') {
// serve the request as if we came to /modern —
// but going through all of Next.js routing
handle(req, res, { ...parsedUrl, pathname: '/modern' })
return
}
handle(req, res, parsedUrl)
}).listen(port)
Pages Router and App Router
Knowing about the two routers of Next.js, we have the right to expect that for the App Router a custom server is configured somehow differently. But no. A custom server works on top of the router-server, while resolving which router a route belongs to happens much deeper, already inside render-server. Therefore, the layer we control through handle has no idea, and should not know, whether it is Pages or App Router. It operates on the raw request and the resolved route, and the distinction between the routers does not exist for it. The same handle serves both architectures, including when they coexist in one application.
Deprecated approaches
Historically, the custom server had a different tool — the app.render(req, res, pathname, query) method. It allowed rendering a specific page directly. Similarly worked the methods renderToHTML(), renderError(), render404(). In recent versions of Next.js, all these methods are marked as deprecated.
The reason for the deprecation: earlier app.render() went straight to rendering, bypassing the router-server, and therefore bypassing middleware, rewrites, redirects, and caching decisions. For a simple case this worked, but it meant that part of the Next.js behavior that a developer saw during a normal run was skipped under this approach.
However, in current versions this difference in behavior no longer exists. If you look at the current implementation of render() inside NextCustomServer, it turns out that it no longer renders anything directly, but only normalizes pathname, reassembles from it, query, and parsedUrl a new req.url, and calls exactly the same this.requestHandler as getRequestHandler(), that is, the very same request handler of the router-server. This means that today app.render() goes through the same staircase as the regular path.
The Next.js team reduces all variants to a single getRequestHandler() so they can freely change the internals, while leaving the render() method and its analogs as thin wrappers with a deprecation warning.
In one of my work projects, the custom server is built precisely on top of app.render(). The application does not run via next start. The process is managed by NestJS on the Express adapter: it owns the HTTP server, routing, guards, and the BFF, while Next.js is invoked only to render a page or serve /_next/* and static files. Page routing is described by a NestJS controller: each route has its own set of @UseGuards (authorization, feature flags) that run on the server before rendering, and @Render('some-page') under the hood turns into a call to app.render(req, res, '/some-page') from Next.js.
Historically, this combination was provided to us by the nest-next package, but it stopped being maintained, and on yet another Next upgrade we rewrote the bridge ourselves — using the same public API of the custom server (next(), prepare(), etc.). For rendering pages, we have so far relied on app.render(req, res, view) rather than getRequestHandler(), for two reasons. First, this preserved the previous contract: @Render('view') in the controller continued to map one-to-one onto app.render(req, res, '/view'), and only the middleware layer needed rewriting, not the controllers themselves. Second, the signature render(req, res, view) literally expresses our model — "the controller has already chosen the page and passed it through the guards; now render exactly this view." getRequestHandler() works differently: it takes the page from req.url. Where the request path is the page path, it can be handed over as is. But in the case of @Get('*') @Render('404') it must render /404 regardless of which URL arrived, so render(req, res, view) fits better in this case. However, the target solution will most likely be to move to getRequestHandler().
Limitations
The custom server has several incompatibilities and limitations.
It is incompatible with the output: 'standalone' mode. This mode is needed to get a small self-contained artifact for deployment. Usually, to run a built application via next start, the server must contain the .next folder, the whole node_modules, and package.json — and most of the weight comes from node_modules, which includes build and development dependencies. Standalone solves this with tracing: during next build, Next statically analyzes the server code, determines which files are actually needed at runtime, and puts into the .next/standalone folder only those — the compiled server, the necessary pieces of node_modules, and its own entry point server.js, which is run via node server.js instead of next start. The result is a folder for which an installed Node.js is enough to start. The conflict with the custom server follows directly from this design. Our server.js does not get into this trace, so when trying to run our own server it will fail at runtime because it will not find the required modules from node_modules.
A custom server is also incompatible with output: 'export'. Export turns the application into a set of static files in out/, and everything that requires a live server is forbidden in it: getServerSideProps, API routes, middleware/proxy, rewrites, redirects, headers, ISR, default image optimization. If any of this is used, the next build itself fails. If the application is fully static and builds fine, a custom server, in the sense in which we considered it here, is usually not needed.
Separately worth mentioning is the useFileSystemPublicRoutes option. By default, Next.js serves every page from pages/ at the path matching its name: the file pages/product.tsx is available at the address /product by itself, without additional routing on our part. When routing is built by a custom server, this gets in the way: we serve the same page at our own address (for example, we render product at /products/:id), but it remains accessible in parallel at the "file-based" /product. The result is one piece of content at two URLs — duplicates for search engines and paths exposed outward that we did not plan.
useFileSystemPublicRoutes: false disables this automatic file-based routing: only the paths our server handles explicitly are rendered on the server, and a direct visit to a file path returns a 404. An important caveat: disabling works only on the server side. Client-side routing (transitions via next/link, the "back" button) can still open such a page — its bundle lives on the client, and client-side navigation does not go through our server. So it is impossible to fully close the path with a single flag: client-side transitions will have to be forbidden separately.
As for dev mode — behind a custom server it continues to work, starting the bundler through the same router-server, but it has enough nuances to be covered separately. Here it is enough to remember that server.js lives outside the compiler, so you should think separately about restarting it on changes.
Summary
A custom server is not a replacement for the Next.js server but a wrapper around it. We take control of the outermost (req, res), but everything that happens with the request afterwards remains the two-layer machine from the previous part.
Most of the reasons why people used to write a custom server — redirects, rewrites, headers, backend-for-frontend logic — are today covered by the config and proxy, without your own process and without giving up platform optimizations. The custom server still exists and still works, but modern Next.js is designed so that you have to resort to it less and less often.
Part 7. The Build Process.
Introduction
In previous parts we have already looked at what can appear in the .next/ directory after running the next build command. Now let's look at how this result is generated: which mechanism turns .tsx files into the artifacts that later serve user requests.
Compiler and bundler
When we talk about building a Next.js application, two distinct tasks stand behind it.
The first is transforming an individual file: take a single .tsx, strip its types, turn JSX into React calls, minify it, etc. This work is done by the compiler. Its key feature is that it looks at a file in isolation. It does not know who imports that file, which other modules exist in the project, what of it ends up on the client and what stays on the server. One file in — one file out.
The second task is building the dependency graph and bundling: walk from entry points through all imports, assemble the module graph, split it into chunks, figure out which code is shared and which is unique to a route, cut out unused code, and distribute everything across target environments — client, Node.js server, Edge. This is the bundler's job, and unlike the compiler, it sees the application as a whole.
In Next.js the roles are distributed as follows: the compiler is SWC, while the bundler is Webpack, Turbopack or, experimentally, Rspack. At the same time, Turbopack uses SWC internally for transforming individual files — only, unlike Webpack, which invokes SWC as an external native module via N-API, Turbopack itself is written in Rust and pulls in SWC as a regular library.
Historically, the Next.js stack moved in one direction. At first it was the classic JavaScript combo: Babel as the compiler, Terser as the minifier, Webpack as the bundler. In version 12 Babel was replaced by default with SWC — a Rust compiler, which sped up transformation by orders of magnitude. Then Turbopack appeared, aimed at replacing the SWC+Webpack combo with a single Rust tool. In the end, as in other parts of frontend, Next.js is migrating its tooling from JavaScript to Rust for the sake of speed.
SWC
SWC (Speedy Web Compiler) — a compiler written in Rust responsible for transforming individual files. For every .ts, .tsx or .js file it performs a predictable set of transformations: erases TypeScript annotations, turns JSX into calls to the React runtime, if necessary lowers modern syntax according to the list of supported browsers, and at the optimization stage minifies the code.
SWC is written in Rust, while Next.js runs in Node.js, so the connection between them is a native module via N-API, a mechanism that allows calling Rust code directly, without a separate process. It works like this: a separate package is published for each combination of platform and architecture — @next/swc-linux-x64-gnu, @next/swc-darwin-arm64, and so on. When installing Next.js, the one matching the target system is pulled in. If no suitable native binary is found, there is a fallback option — a WebAssembly build. It is slower than native, but works anywhere.
Besides basic transformations, SWC applies a set of transformations specific to Next.js. In production it can strip console.* calls and remove service attributes like data-testid. For CSS-in-JS there are transforms that provide stable class names and correct behavior during server rendering.
Worth special attention are modularizeImports and optimizePackageImports — they expand imports from barrel files. This is a practically important thing. When we write import { Button } from 'ui-kit', and ui-kit/index.ts re-exports hundreds of components, naive bundling risks pulling them all into the graph. The transform rewrites such an import into a targeted one, pointing directly at the specific module, so that only what is actually used ends up in the bundle. modularizeImports does this according to a path pattern specified in the config, while optimizePackageImports works automatically since it builds a map of exports.
Finally, it is precisely at the SWC level that the "use client" and "use server" directives are processed — the serverComponents and serverActions transforms are responsible for this. Here server actions receive their identifiers, and the boundaries between server and client code are marked for the bundler's subsequent steps.
Babel is also supported in Next.js as an alternative option. If the project has a Babel configuration (.babelrc or babel.config.js), Next.js automatically switches to Babel. But you pay for this by disabling SWC transforms, which means losing speed and part of the optimizations.
So that transformation does not bottleneck on a single thread, there is a worker pool. This is Next's own infrastructure built on top of ordinary Node.js worker_threads, managed by the SWC native binding itself: via the registerWorkerScheduler method it asks Next to create and terminate threads, and Next acts merely as a factory of these threads. This way the compiler's work is spread across multiple cores.
Webpack
Webpack — the bundler on which Next.js spent most of its history, and it is its configuration that still sets the reference for what the final build should look like. Even after switching to Turbopack, it is useful to understand the Webpack model, because Turbopack largely reproduces its result.
Next.js does not build the application in a single pass. The configuration-building function is called separately for each target environment — client, server for the Node.js runtime, and edge for the Edge runtime. Each environment has its own external dependencies, its own output format, its own runtime. The client bundle must run in the browser, the server bundle must have access to Node.js APIs, edge must fit within the constraints of a Web-compatible environment. Essentially these are three different Webpack compilers, whose results are later stitched together into a single build.
Layers
Inside each environment webpack uses the layers mechanism. We already encountered layers in part three, when we talked about the two builds of React — the RSC runtime (server React without useState and useEffect, serializing the tree into a Flight stream) and the SSR/client runtime (ordinary React). They are switched by the react-server export condition in React's own package.json, and back then we noted that Next enables this condition pointwise for a layer in the bundler.
A layer is a kind of label that the bundler attaches to a module. On its own it does not change the module's content, but it changes two things in how the module is processed: which conditions apply when resolving its imports, and which loaders it goes through. The full list of layers looks like this:
const WEBPACK_LAYERS_NAMES = {
shared: 'shared',
reactServerComponents: 'rsc',
serverSideRendering: 'ssr',
actionBrowser: 'action-browser',
apiNode: 'api-node',
apiEdge: 'api-edge',
middleware: 'middleware',
instrument: 'instrument',
edgeAsset: 'edge-asset',
appPagesBrowser: 'app-pages-browser',
pagesDirBrowser: 'pages-dir-browser',
pagesDirEdge: 'pages-dir-edge',
pagesDirNode: 'pages-dir-node',
};
The key ones for separating client and server are rsc (server components), ssr and app-pages-browser (the App Router client bundle).
The main thing layers do is manage module resolution. For the rsc layer, Next prepends the react-server condition to the beginning of the resolver's condition list. Therefore import ... from 'react' inside a server component resolves to the trimmed-down server build of React (where there is no useState or useEffect), whereas exactly the same import in the ssr layer or in the client layer resolves to ordinary React. Thus a server component cannot accidentally call a client hook, because in its layer that hook does not exist.
Loaders and plugins
Inside Webpack, SWC is connected as a loader (next-swc-loader), through which every module passes. Layers also determine which SWC loader config a module gets: Next assembles separate loader instances for each layer. But besides next-swc-loader, several more specialized loaders participate in the chain:
next-flight-loaderprocesses RSC modules, marking the boundaries between server and client code;next-app-loaderturns App Router file conventions —page,layout,loading,error— into route modules;next-font-loadertakes care ofnext/font: it downloads and inlines fonts at build time to eliminate an extra network request at runtime;- the chain of
postcss-loader,lightningcss-loaderandmini-css-extractruns styles through PostCSS plugins and extracts CSS into separate files.
If loaders transform individual modules, then plugins operate on the build as a whole. Part of them is exactly what generates those manifests we examined in part two.
A special place belongs to flight-manifest-plugin and flight-client-entry-plugin. It is here that the separation into server and client physically appears. The flight-client-entry-plugin plugin walks the module graph, finds "use client" boundaries and creates a corresponding client entry point for each server one. And flight-manifest-plugin generates the manifest of client references, by which the runtime maps placeholders from the RSC Payload to real client chunks. Everything we discussed in previous parts about the Flight Protocol relies on what happens at this step of the build.
Chunk splitting
How Webpack decides what to put into which file is defined by Next.js itself in the optimization.splitChunks section. For the client production build the logic is as follows. A framework chunk, the backbone, is separated out separately — it contains the code that changes least often: React, ReactDOM, Next.js itself. Keeping it separate pays off thanks to long-term caching in the browser. Large dependencies from node_modules are extracted into lib chunks: if a library's size exceeds ~160 KB (160000 bytes), it gets its own chunk and does not inflate the overall bundle. Webpack's own small runtime is moved out into a separate runtime chunk.
Turbopack
Turbopack — a bundler that Vercel writes specifically for Next.js. In version 16 it became the default bundler, while Webpack remained available behind a flag.
At the core of Turbopack lies the incremental computation system turbo-tasks. The idea is to model the entire build as a graph of memoized functions.
How Turbopack is structured
turbo-tasks operates with several primitives. Functions are units of execution and invalidation; a specific call of a function with arguments is called a task. Values are the data that functions create and return. A reference to a task's result is a Vc (Value Cell): not the value itself, but a pointer to a cell whose contents may change upon recomputation. When one task reads another task's Vc, a dependency forms between them, and turbo-tasks remembers it.
All tasks and their dependencies form a task graph. Then incrementality kicks in. When something changes — for example, a file's contents — the system marks the corresponding task as "dirty", and invalidation propagates bottom-up through the graph: from the changed leaf to the tasks that depend on it. Only the affected subgraph is recomputed; everything untouched by the change stays as is.
Incrementality is easy to perceive as a dev feature — recompute only what changed when saving a file. But in production it has an equally important manifestation. Task results can be persisted to disk between runs. This is the persistent build cache: a repeated next build, locally or in CI with a saved cache, does not start from scratch but reuses unaffected results. This is how the incremental model speeds up production builds.
On top of turbo-tasks the bundler is built, split into Rust packages, for example:
turbopack-corecontains the module graph and the chunking algorithm;turbopack-ecmascripthandles JS and TS processing and relies on SWC;turbopack-cssprocesses styles;turbopack-resolveresolves imports to modules;turbopack-nodecan execute Node.js code right inside the graph, for example for fetching data at build time.
The main architectural difference from Webpack is a single graph. Webpack launches separate compilers for client, server and edge and stitches the results together. Turbopack, however, builds one dependency graph for all target environments at once.
How Next.js drives Turbopack
From Next.js's perspective, Turbopack is a native module available via N-API, for which Next creates a Project object to communicate with it and passes build options. Next requests entry points from Turbopack through this Project, and then Turbopack assembles from the results the same kind of manifests that Webpack also generates. The server layer, which we examined in part five, should not know which bundler built the application — it reads manifests in a unified format regardless of whether Webpack or Turbopack produced them.
Other steps in next build
Compilation and bundling are the central, but far from the only stage of the build. Inside building a Next.js application, a long pipeline runs where bundling is just one of the steps.
First, the build identifier (buildId) is generated. Then next.config.js is loaded and validated, redirects, rewrites and headers are resolved. Next, Next walks app/ and pages/, discovers all routes and builds a map from them — routes-manifest. Only after that does compilation begin: webpackBuild or turbopackBuild, depending on the chosen bundler. Here SWC and the bundler do their work, and chunks and most manifests are generated.
After compilation, Next traces the used files (more on that below), and then analyzes each route, determining its rendering strategy: what can be pre-rendered statically and what will have to be computed on every request. Routes marked as static are immediately pre-rendered to HTML. At the end, if standalone output is enabled, a self-sufficient directory for deployment is formed, and a tree with the ○, ● and ƒ icons next to each route along with chunk sizes is printed to the console.
File tracing deserves a separate mention. Its task is to statically analyze which files are actually needed for each server entry to work: not only our code, but also transitive dependencies from node_modules. The result is a list of minimally required files, from which the output: 'standalone' mode assembles a compact directory containing only what is actually used at runtime, without all of node_modules wholesale.
Summary
Behind the word "build" in Next.js hide two layers — the compiler, working with a file in isolation, and the bundler, seeing the application as a whole. Both of these layers are right now actively migrating from the world of JavaScript to the world of Rust. Babel gave way to SWC, and Webpack is gradually being replaced by Turbopack.
Flexibility comes at the cost of complexity: three (counting Rspack) possible bundlers, three target environments, layers, manifests, file tracing — and all of it must produce a compatible result that the server layer can serve without knowing the details of the build.
Part 8. Dev Mode.
Introduction
In the seventh part we looked at how next build turns source code into artifacts, and in the fifth and sixth — how the server layer serves those artifacts. Both pictures relied on the same assumption: by the time the first request arrives, the manifests and chunks are already built, and the list of routes is known.
In dev mode this assumption does not hold. next dev lives in a mode where the build runs in parallel with serving requests, and its results must reach an already-open browser. In this article we will look at the nuances of this process in more detail.
Processes inside dev
The next dev command uses two processes internally. The parent process forks a child and watches over it. All of the logic (the HTTP server, the bundler, rendering) lives in the child process.
This separation exists for the sake of the restart scenario. The child process separately watches the configuration files, and when next.config.js changes, it exits with a special exit code. The parent reacts to this code and brings up a new child process with the same options. This is how the new config gets applied.
Inside the child process live our familiar router-server and render-server. Between them sits the bundler — Webpack or Turbopack — as a long-lived object that can be accessed at runtime.
Alongside the main process, Next keeps a few more workers; for example, one of them serves getStaticPaths and generateStaticParams. These functions answer the question of which specific paths of a dynamic route are considered known in advance. By the time they are called in the dev server, modules have already been loaded, previous requests have run, and state has accumulated. If their implementation accidentally relies on that state, everything will work in dev, but the production build will fail. So for each call Next creates a separate worker and immediately destroys it, reproducing build conditions.
Routes without manifests
In production mode, on startup the router-server reads the manifests to get information about the build (page lists, rules for middleware, etc.) and then uses them when checking the file system. In dev there is no separate build step, so the route map is built by the watcher. The directories under observation are app/ and pages/, plus a point set of files: middleware (a.k.a. proxy) and instrumentation candidates, .env files, and tsconfig.json with jsconfig.json.
On every change, the walker traverses the files again and rebuilds everything that follows from them: lists of pages, route handlers, layouts and slots, middleware matchers, the set of static metadata files. It is also what detects conflicts when the same path is described both in app/ and in pages/, and it also generates route types into .next/types. The router-server uses this data for the file system check step. Thanks to this, a new page appears in dev without restarting the server. If the set of routes has changed, the watcher additionally reports this to the browser's client-side router.
On-demand compilation
In production, Next.js loads already-built modules. In dev, if a page has not been built yet, the request waits until the bundler builds it.
Webpack by itself has no "lazy" compilation mechanism: it builds everything listed in its entry points. That is why this mechanism is written in Next, on top of the bundler's ordinary API, and is built around a map of entrypoints that lives in the process's memory. Each entry in it knows its own status (added, being built, built), the time of last access, and an "marked for unloading" flag (more on that shortly). If the entry already exists and it is built, an access for a page simply updates the activity time and clears the flag, and no compilation is started. If there is no entry, one is created with the status "added", and only then does the build need to be started.
The list of entrypoints is rebuilt anew before each build, and all living entries take part in it. Only the modules of changed files are marked as invalid; everything else is taken from the module cache. But the work done on top of the modules — building the chunk graph, code generation, hashing — goes through the entire compilation as a whole.
Over a long session the number of entrypoints grows, and since all of them participate in each build, the cost of every iteration grows too. That is why inactive pages get unloaded. Every six seconds a timer walks the map and flags everything that is already built and has not been accessed for longer than a minute. Then the compilation stage throws the flagged entries out of the map, and the modules that were connected only through them leave the build.
The server learns that a page is "active" from pings that come through the same socket through which updates arrive. Pages Router sends its pathname. App Router sends the entire state tree of the router, and the server extends the life of all entrypoints that follow from that tree.
With Turbopack there is no separate mechanism like Webpack's: the turbo-tasks graph is computed on demand, and unused things are simply not recomputed.
The update channel
The reverse channel through which Next.js reports build updates to the client is an ordinary WebSocket. The handler for such a connection lives in the router-server. If the server is running in dev and the path starts with /_next/hmr, the connection is handed over to the bundler. Everything else follows the ordinary path — through route resolution, rewrites and, if necessary, proxying. For sockets the origin is checked as well (the list of allowed origins is set by the config).
Quite a few message types travel over this channel. There is the compilation lifecycle: a build started, a build finished, state synchronization upon connection. There are changes to the route map: a page was added, a page was removed. There is a classification of what exactly changed: client-side changes, server-only changes, middleware changes, server component changes, changes to the set of statically known parameters. And there is also a command to reload the page.
Fast Refresh
Fast Refresh is a feature of React's development environment that instantly shows code changes in the browser while preserving the current state of components (for example, typed text or open tabs).
Fast Refresh can be confused or merged together with Hot Module Replacement, but their levels differ. HMR is the bundler's general mechanism. It knows nothing about React and by itself cannot decide what to do with component state. Fast Refresh, on the other hand, knows how to find a mounted component, swap its implementation, and determine whether the state can be preserved. It is implemented by the react-refresh package from the React repository, and Next includes this package in its dev mode.
For the client build in dev, a flag is enabled that reaches SWC as an option of the React transform. The transform registers every component of a module under a stable identifier and computes a signature for it — a fingerprint of which hooks are used and in what order. In parallel, the react-refresh runtime is added to the build; it knows how to find mounted components by these identifiers and swap their implementations.
From this, the following rules follow directly. If a module exports only components, the implementation can be replaced while preserving state. If the signature of hooks has changed, the state cannot be restored and the component is remounted. If a module exports something besides components, there are no guarantees: the update travels up the dependency graph and, in the limit, can reach a full page reload.
And the main limitation: the flag is enabled only for the client layer. Fast Refresh physically does not exist for server code.
Server-side changes
A server component does not live in the browser — only the result of its rendering is there. Therefore a different mechanism works at this level.
At the end of a compilation, Next on the server compares the sets of changed pages across different target environments and sorts the changes into categories. Middleware edits produce one message, edits to the server side of Pages Router pages produce another, and edits to server components lead to a third.
In parallel, we need to get rid of old modules on the server itself. The built server chunks lie on disk and are loaded with an ordinary require, so Next cleans them out of Node.js's module cache.
On the client, a message about server-side changes is handled as follows. If the page is currently in an error state, a full reload simply happens. In the normal case, a refresh of the router is called inside startTransition: the client requests a new RSC Payload and applies it on top of the current tree.
A custom server in dev
What nuances of dev mode do we need to know about when using a custom server?
Calling next({ dev: true }) brings up a full-fledged dev mode with all the machinery described above. This works because handle is the router-server's handler, and the bundler is embedded precisely in the router-server, so along with routing we also get the on-demand build.
Then the nuances begin. The first and most noticeable one: HMR travels over WebSocket, that is, through the upgrade event of our HTTP server, while handle handles ordinary requests. If the upgrade is not forwarded to Next separately, pages will open fine but stop updating.
It is also worth remembering that our server.js does not pass through the compiler and is not under the watcher, so its changes are picked up by nothing. You will have to organize the restart of the process when editing your own server yourself.
Summary
Dev and prod modes in Next.js differ in a number of other aspects as well.
Caching in dev is deliberately almost disabled. Prefetching links when they appear in the viewport is also turned off: it would require compiling every page that visible links lead to. Prefetching on hover, however, remained.
The build is different too: there is no minification, no production chunk splitting strategy, but there are source maps and a development build of React with its checks. On top of all this, React in Strict Mode renders components twice.
It follows that any performance measurements in dev measure only dev. This mode is optimized for the edit loop, not for response speed, and therefore says nothing about how the application will behave in combat.
Part 9. Optimization Techniques.
Introduction
Optimization in Next.js is a consequence of the decisions we covered in all previous parts. So in this part we bring together what we have already seen and add several mechanisms we haven't had a chance to talk about before.
Static as the default optimization
The cheapest request is one that requires no rendering at all. Next.js tries to determine this at the build stage: if a route does not read cookies(), headers() or other dynamic APIs, it is rendered once during next build and from then on served as ready-made HTML.
The ability to serve static content that can still update is called ISR (Incremental Static Regeneration). The idea follows the stale-while-revalidate model: a page whose revalidate period has expired does not block the user with recomputation — they are given the current, stale version, while recomputation starts in the background and replaces the cache entry for subsequent requests.
After the router-server has resolved the route and passed it to the render-server, the latter goes to the incremental cache before rendering, having built a key from the path and parameters. Then there's a fork:
- there is no entry — we render, put the result into the cache together with the
revalidatevalue taken from the route configuration; - the entry exists and hasn't expired — we serve the stored HTML and RSC Payload, no render is invoked at all;
- the entry exists but has expired — we serve it as is, while the render goes into the background and, once finished, overwrites the entry.
Regular static content is a special case of the same mechanism: its entry effectively has an infinite revalidate, so the third branch never triggers.
Partial Prerendering
Dividing things into static and dynamic used to be a choice at the level of the entire route: either the whole route was pre-rendered, or the whole thing was computed on every request. A single personalized widget in the corner of the page — a cart or recommendations, for example — switched the entire page into dynamic mode.
Partial Prerendering (PPR) removes this limitation by allowing static and dynamic content to coexist in a single response. At the build stage, Next.js renders the page and stops at Suspense boundaries: what is outside the boundaries goes into the static HTML shell, and what is inside turns into postponed state, a serialized state from which rendering can be resumed later.
On a request, the server immediately serves the ready-made shell, because it already sits in the cache like regular static content, then starts a render from the stored postponed state and streams the dynamic pieces into the same stream, filling in the holes that were left behind.
This model has a characteristic trap: reading searchParams or any other dynamic API directly in the page component makes everything around it dynamic, because there is no Suspense boundary between that read and the root of the page — which means there is also no point at which the render could be suspended, deferring only that part. Reading dynamic data should be pushed down into a child component and that component wrapped in Suspense — then only it falls into the hole, not the whole page.
Bundle splitting
In part seven we covered how Next.js extracts React and itself into a framework chunk, and large libraries from node_modules into separate lib chunks when they exceed ~160 KB. This logic works without our involvement and identically for all routes.
Alongside it work the SWC transforms modularizeImports and optimizePackageImports, which we also mentioned in part seven. When we write import { Button } from 'ui-kit', and ui-kit/index.ts re-exports hundreds of components, naive bundling risks pulling them all into the graph. The transform rewrites such an import into a targeted one, directly to a specific module. The difference between the two options is where the rewriting rule comes from: modularizeImports requires setting a path pattern in the config manually, while optimizePackageImports builds the export map itself and therefore works automatically.
But next/dynamic is a tool controlled by us rather than by the bundler. It is built on top of Loadable, a fork of the react-loadable library. A component wrapped in dynamic(() => import('./Component')) ends up not in the main chunk of the route but in a separate one, which the bundler isolates along the import() boundary, and it is loaded on demand rather than on first visit to the page.
This mechanism has several practical details. By default, Loadable waits 200 ms before showing a loading state — so as not to flash a spinner on fast loads. It is also possible to pass ssr: false and completely exclude the component from server-side rendering.
next/image
The Image component solves a specific problem: the browser doesn't know in advance how big an image will be, which causes the page to jump around until it loads. Image requires specifying width/height (or using a static import, from which the dimensions are derived automatically) and reserves space for the image before it has loaded.
Then the server-side optimizer comes into play — the /_next/image endpoint. The router-server, having parsed the path, hands such a request off to the image optimizer. As input, the optimizer takes url, the width w and quality q. Inside, transformation happens via sharp: the image is resized to the requested width and re-encoded into a format that, judging by the Accept header, the browser supports. The result of such a transformation is cached on disk in .next/cache/images, so the next time an already computed variant can be served without invoking sharp again.
From the point of view of what we serve to the browser, Image generates the srcset itself based on deviceSizes, so the browser picks the size appropriate for its viewport.
next/font
Fonts are the source of two problems at once: an extra external request and layout shift until the font has loaded. next/font solves both at the build stage.
For this, next-font-loader is used — one of the specialized loaders of the bundler. In the case of next/font/local it reads files from the project, computes metrics, generates @font-face. In the case of next/font/google, downloading is added on top: the loader goes for CSS to fonts.googleapis.com, extracts links to files from it and downloads them, to be placed alongside the rest of the application's static assets.
The moment of downloading is the moment the module is processed by the loader. In a production build this is the execution of next build, while in dev the font is downloaded when the page importing it is compiled for the first time. The price of failure differs too: dev mode responds to an unreachable Google Fonts with a warning and a fallback font, whereas a production build will fail. For CI without outbound access, this means that next/font/google would have to be either proxied or replaced with next/font/local.
The second part of the optimizations is automatic selection of font metrics for fallback. next/font knows the metrics (ascent, descent, line gap) both of the target font and of the system fonts that the browser will show while the web font hasn't finished loading, and generates adjustments for the fallback font via size-adjust and related CSS properties. The point is for the text placeholder to take up as much space as the final font will, so that switching between them doesn't shift the layout.
next/script
Third-party scripts — analytics, chats, widgets — are a classic source of render blocking. The browser has to download and execute them before continuing to build the page. next/script gives explicit control over when this happens through the strategy parameter:
beforeInteractive— before hydration, for critical code (anti-fraud, polyfills);afterInteractive— right after hydration;lazyOnload— during browser idle time, for anything non-critical;worker— in a separate worker via Partytown, away from the main thread.
By default (afterInteractive) the script doesn't interfere with the first render at all — it loads in parallel and executes when the main thread has already freed up after hydration.
File tracing
In part seven, while going through the steps of next build, we mentioned file tracing. Its task is to statically analyze which files are actually needed by each server entry point: not only our code but also transitive dependencies from node_modules. The result is a list of minimally necessary files, and it is exactly from this list that the output: 'standalone' mode assembles a compact directory for deployment.
From an optimization standpoint it doesn't speed up the response to the user, but it does reduce the image size, and with it the container build time, deployment time, and cold start on serverless platforms.
Summary
Next.js tries to shift as much work as possible to the build stage and to narrow down as much as possible what remains at runtime. Static and ISR move rendering itself into the build, PPR moves the portion of rendering that doesn't depend on the request. next/font moves font loading and hosting into the build, while SWC transforms and next/dynamic make the decision about what gets into the first chunk. next/image is the exception: it cannot move the transformation of arbitrary external images into the build, so instead it caches the result so that the transformation happens only once for each combination of URL, width and quality.
