Next.js: A Hands-On Guide

Next.js handles a lot behind the scenes, so to truly grasp how everything works, starting from scratch is ideal. While create-next-app is a great and recommended option, the best way to learn is by doing the hard work yourself.
Project initialization
First off, create a directory and initialize npm and git:
mkdir nextjs-app
cd nextjs-app
npm init
git init .
Then, install the required packages:
npm i next@latest react@latest react-dom@latest
Add the following scripts to package.json:
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
}
In the scripts configuration we can see the commands used for development and production builds. This brings us to another important subject: bundling and compilation.
Builds in Next.js
Regarding the Next.js builds, two components come in play:
The bundler – responsible for packaging modules into bundles.
The Next.js Compiler (SWC) – a Rust-based compiler/transpiler used for transforming JavaScript/TypeScript, JSX, and some CSS handling. This replaces Babel for individual files and Terser for minifying output bundles.
For your local development, Next is using Turbopack, Rust-based, incremental bundler designed to make local development and builds fast—especially for large applications. It is integrated into Next.js, offering zero-config CSS, React, and TypeScript support.
Important note: with Turbopack, the build artifacts are kept entirely in memory — not written to disk.
At the time of the writing, Next.js is still using Webpack for bundling in production builds. Plan is to switch to using Turbopack for production builds as well.(NOTE: With Next. js 16 (October 2025), Turbopack became the default bundler).
Both Webpack and Turbopack integrate with SWC, they use it as the underlying transform step.
Interesting fact: Webpack is not used as a npm dependency in Next.js, instead it has pre-compiled Webpack, and a bunch of other libraries, which you can see here: https://github.com/vercel/next.js/tree/canary/packages/next/src/compiled.
Initial Directory Setup
Initially, we will use the pages router and we will setup the directory accordingly.
Next.js is using file-system based routing, which means routes in your application are determined by the structure of your files. Structure of directories should look like this:
nextjs-app
└───pages
│ └───_app.tsx
| └───_document.tsx
| └───index.tsx
└───package-lock.json
└───package.json
First, create a pages directory at the root of your project. Then, add an index.tsx file inside your pages folder. This will be your home page (/):
export default function Page() {
return <h1>Hello, Next.js!</h1>
}
In React, index.tsx is the starting file of your application. The similar concept in Next.js is the app component that we just added. Next.js is based on the concept of pages, and every page is initialized by the app component. By default, the App component is hidden, it's internal in Next.js, but we can override it to control the page initialization, if you want to persist layout between page changes, keep the state when navigating pages, if you want to do custom error handling, inject additional data into pages, or add global CSS.
So, we will override the default App component in Next.js by creating a _app.tsx file and defining our own App component. Add an _app.tsx file inside pages/:
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
There is one more Next.js internal component that we can override and that is the Document component. This component controls the top level HTML structure of your Next.js application. Here, you can introduce custom behavior like fonts, scripts, icons, etc. To override it, add a _document.tsx file inside pages/ to control the initial response from the server:
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
Typescript support
Next.js comes with built-in TypeScript support. Since we are using Typescript in the project, Next.js will detect this once we run next dev and it will install the required dependencies and add a tsconfig.json file with the recommended config options.
Set up ESLint
To add ESLint support, add next lint as a script to package.json:
"scripts": {
...
"lint": "next lint"
}
Then, run npm run lint and you will be guided through the installation and configuration process. In the prompt you will be given multiple options:
Strict: Includes base Next.js ESLint rules + Core Web Vitals performance and accessibility checks.
Base: Includes Next.js' base ESLint configuration.
Cancel: Skip configuration. Select this option if you plan on setting up your own custom ESLint configuration.
If Strict or Base are selected, Next.js will automatically install eslint and eslint-config-next as dependencies in your application and create a configuration file in the root of your project.
Set up Imports and Aliases
Next.js has in-built support for the "paths" and "baseUrl" options of tsconfig.json and jsconfig.json files.
baseUrltells the compiler what directory to treat as the root for non-relative imports.pathsallows you to define aliases (like@/components) that map to actual directories.
Relative imports use ./ or ../ to describe where the file is relative to the current file’s folder. Non-relative imports don’t start with ./ or ../ — instead, they use a module name or an alias.
For example:
// Before
import { Button } from '../../../components/button'
// After
import { Button } from '@/components/button'
We will add the following configuration to tsconfig.json:
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}
This means whenever there is an import path that starts with @/, it will be replaced with ./ (the project root).
Static Generation(SSG) in Pages Router
Next.js pre-renders every page in pages/at build time (SSG) by default — unless you use data-fetching functions that change the behavior:
getStaticProps: page is statically generated with data.
getServerSideProps: page is rendered on every request.
To verify that the index.tsx page is rendered using SSG we can do the following:
Build the app using
npm run build.If the page is statically generated, you’ll find a corresponding HTML file in:
.next/server/pages/index.html.
Server Side Rendering(SSR) in Pages Router
With SSR, the page is rendered on every request. Lets see what happens when we use getServerSideProps method. Change the content of the index.tsx component:
export default function Page({ posts }) {
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
export async function getServerSideProps() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts = await res.json();
return {
props: {
posts,
},
};
}
If you build the app again, you will notice that the .next/server/pages/index.html file was replaced by .next/server/pages/index.js. This file contains a render function — essentially the code that runs on the server to produce HTML on demand.
Pages Router vs App Router
Notice that our pages are inside pages directory. This means that we are using the Pages Router, which was the main way to create routes before Next.js 13. It's still supported in newer versions of Next.js, but it is recommended to migrate to the new App Router.
To switch to using the App Router, change the name of the directory pages to app. If we try to build the app now, we will get the error like this:
Failed to compile.
./app/_document.tsx
1:1 Error: `<Document />` from `next/document` should not be imported outside of `pages/_document.js`. See: https://nextjs.org/docs/messages/no-document-import-in-page @next/next/no-document-import-in-page
This is because you cannot use _app.tsx or _document.tsx in the App Router, _app.tsx is replaced by app/layout.tsx, which plays the equivalent role and _document.tsx is replaced by the <html> and <body> tags inside layout. To resolve the errors remove _app.tsx and _document.tsx files. Also, we need to add the layout.tsx file:
export default function MainLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
</html>
)
}
We will also need to rename index.tsx to page.tsx, to follow the App Router naming conventions. After doing that, also change the content of the file to:
// app/page.tsx
export default function Page() {
return <h1>Hello, Next.js using App Router!</h1>
}
Now, build the app and examine the output. You will notice that the pages are now contained in the .next/server/app directory. There you will find the page.js file, but no page.html. Whats up with that? Since this is a static component that is not using data fetching of any kind, shouldn’t it be rendered as a HTML page using SSG? This is where React Server Components come into play.
npm run build and then npm run start. This way you will be able to use and run the production build. Development builds are messy and harder to debug.React Server Components
By default, layouts and pages are Server Components. Server Components are only rendered once on the server into a special data format called the React Server Component Payload (RSC Payload). The RSC Payload is a compact binary representation of the rendered React Server Components tree. It's used by React on the client to update the browser's DOM. The RSC Payload contains:
The rendered result of Server Components
Placeholders for where Client Components should be rendered and references to their JavaScript files
Any props passed from a Server Component to a Client Component
This is the simplified version of how the page source looks now:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Preloaded and async JS bundles used by Next.js -->
<script src="/_next/static/chunks/4bd1b696-c023c6e3521b1417.js" async></script>
</head>
<body>
<!-- React Server Components streaming placeholder -->
<div hidden><!--\(--><!--/\)--></div>
<main>
<h1>Hello, Next.js!</h1>
<!--\(--><!--/\)-->
</main>
<!-- Next.js internal runtime scripts for RSC hydration -->
<script src="/_next/static/chunks/webpack-078f6dfb37dff419.js" id="_R_" async></script>
<!-- Serialized RSC data -->
<script>
(self.__next_f = self.__next_f || []).push([0]);
</script>
<script>
self.__next_f.push([1, "…serialized payload…"]);
</script>
</body>
</html>
At the bottom of the HTML document you will notice a lot of script tags that are calling self.__next_f.push. This is actually how RSC payload is injected into the client side of the application. This payload is available at self.__next_f and it is used to create the Virtual DOM and hydrate the application once the JS bundles are loaded. You can use console.log(self.__next_f) to inspect the RSC payload and this is how it looks in our case:
If you inspect the content of the payload, you will notice this part:
which represents the RSC payload from the home page component and it is used to hydrate the page on the client.
Because of how the scripts are injected, you may sometimes see the HTML “duplicated” or repeated in the page source inside these payload strings. Some developers have complained that this makes pages large or leads to redundancy. This is something to keep in mind, because the bigger number of RSC you have, bigger will be the RSC payload in the HTML.
Client-side Transitions and RSC
RSC payload is also used when performing client-side transitions. Client-side transitions in Next.js are page navigations handled entirely by JavaScript on the client, without doing a full browser reload. Client-side transitions are used when navigation is handled by <Link> components.
Prefetching is the process of loading a route in the background before the user navigates to it. This speeds up the navigation between routes in your application, because by the time a user clicks on a link, the data to render the next route is already available client side.
Next.js automatically prefetches routes linked with the <Link> component when they enter the user's viewport.
Here is the modified version of home page that is using the <Link> component:
// app/page.tsx
import Link from 'next/link';
export default function HomePage() {
return (
<div>
<h1>Home</h1>
<Link href="/posts">Go to Posts</Link>
</div>
);
}
We also need to add the new Posts page:
// app/posts/page.tsx
export default async function Page() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts = await res.json();
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
Notice how this component is similar to the one we used with the Pages Router, but here the component itself is async, meaning you can fetch data directly within the component itself, as it supports async/await.
Now, when you run the application and open the home page, you will notice an additional fetch request that looks like this:
REQUEST:
GET /posts?_rsc=3lb4g HTTP/1.1
RESPONSE:
HTTP/1.1 200 OK
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/x-component
Transfer-Encoding: chunked
1:"$Sreact.fragment"
2:I[9766,[],""]
3:I[8924,[],""]
5:I[4431,[],"OutletBoundary"]
...
39:["$","li","99",{"children":"temporibus sit alias delectus eligendi possimus magni"}]
3a:["$","li","100",{"children":"at nam consequatur ea labore ea harum"}]
If you click on the link, there will no new requests in the network tab, because the required RSC payload to show the page is already loaded. However, new RSC fetch request will be made in case you disable prefetching or the content that needs to be loaded is dynamic(more on that later).
Static Content and RSC
If you take a closer look at the response headers in the browser console you will see a header like this:
x-nextjs-cache: HIT
This implies that the page was fetched from some kind of cache. This kind of cache in Next.js is known as Full Route Cache.
With the App Router, content of the static routes is not generated into a single HTML file. Instead, the content of the static pages is generated during the build time and stored to Full Route Cache. More specifically for our homepage, its HTML and RSC Payload will be stored in the Full Route Cache so that it is served faster when a user requests / route.
We can also see that / route is marked as static prerendered content in the build output:
Route (app) Size First Load JS
┌ ○ / 127 B 102 kB
└ ○ /_not-found 995 B 103 kB
+ First Load JS shared by all 102 kB
├ chunks/255-4efeec91c7871d79.js 45.7 kB
├ chunks/4bd1b696-c023c6e3521b1417.js 54.2 kB
└ other shared chunks (total) 1.88 kB
○ (Static) prerendered as static content
Dynamic Content and RSC
Dynamic Rendering is used when you want HTML to be generated at the request time. This allows you to serve content based on request-time data.
If you take a look at the build output now, you will notice that the component is marked as as static prerendered content. How can we dynamically render the component, so HTML is generated at request time and not build time?
A component becomes dynamic if it uses the following APIs:
cookies
headers
connection
draftMode
searchParams prop
unstable_noStore
fetch with { cache: 'no-store' }
Using these APIs throws a special React error that informs Next.js the component cannot be statically rendered, causing a build error.
To test this out, lets use fetch with { cache: 'no-store' }:
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
cache: 'no-store',
});
And now in the build output we can see that the component is dynamically rendered:
Route (app) Size First Load JS
┌ ƒ / 127 B 102 kB
└ ○ /_not-found 995 B 103 kB
+ First Load JS shared by all 102 kB
├ chunks/255-4efeec91c7871d79.js 45.7 kB
├ chunks/4bd1b696-c023c6e3521b1417.js 54.2 kB
└ other shared chunks (total) 1.88 kB
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
This time if you open the network console, you will notice that x-nextjs-cache: HIT is missing.
At this point, when you open the page, it is rendered almost instantly. But what would happen if the fetch request is taking longer to finish. To simulate this, lets add a timeout of 5 seconds before the request:
await new Promise((resolve) => setTimeout(resolve, 5000));
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
cache: 'no-store',
});
Now, if you open the page again, you will have to wait around 5 seconds for the page to get rendered. This is a good example of how SSR works by default:
On the server, fetch data for the entire app.
Then, on the server, render the entire app to HTML and send it in the response.
Then, on the client, load the JavaScript code for the entire app.
Then, on the client, connect the JavaScript logic to the server-generated HTML for the entire app (this is “hydration”).
The key part is that each step had to finish for the entire app at once before the next step could start. This is not exactly efficient, so the React team came up with the solution: streaming.
Streaming in RSC
When RSC is mentioned, you can always see mentions of streaming. But what exactly is streaming in RSC?
Under the hood, Next.js is using HTTP chunked transfer encoding.
When you open the application in the browser for the first time, and you inspect a network request for the HTML page in the console, you will see a header like this:
transfer-encoding: chunked
This confirms that chunked transfer encoding is used to get the HTML. On subsequent page loads, there will be no streaming involved, since the page will be fetched from the browser cache(observable by the 304 Not Modified status code).
A key point is that the browser processes HTML in a streaming fashion. As soon as the browser gets any portion of a page's HTML, the browser starts processing it. The browser can then render it well before receiving the rest of a page's HTML.
For the initial render, the browser will not typically wait for:
All of the HTML.
Fonts.
Images.
Non-render-blocking JavaScript outside of the
<head>element (for example,<script>elements placed at the end of the HTML). This is why embedded RSC payload is contained in the<script>elements at the end of the page.Non-render-blocking CSS outside of the
<head>element, or CSS with amediaattribute value that does not apply to the current viewport.
But, as observed in the previous example with the slow loading dynamic component, if the page is marked as dynamic, the user will have to wait for it to fully render on the server to get the first byte of the stream. To resolve this issue, we can wrap the slow loading part of the page with <Suspense>.
Suspense and Streaming
Once you introduce <Suspense> boundaries:
React can render out of order.
It sends HTML for ready parts first.
For components still fetching data, React sends placeholders (fallbacks).
Later, when the data resolves, React streams the missing parts with inline snippets inside the
<script>tags, that dynamically inserts the HTML in the correct place — even if that’s earlier in the DOM.
There are two ways you implement Suspense boundaries in Next.js:
At the page level, with the
loading.tsxfile (which creates<Suspense>for you).At the component level, with
<Suspense>for more granular control.
Continuing with our previous example of the dynamic component with slow loading, lets add the Loading component:
// app/loading.tsx
export default function Loading() {
return <div>Loading...</div>;
}
Now, open the page again and observe that the page is loaded instantly and you can see that Loading… fallback is displayed. If you look at the source code you will notice a placeholder for the posts list that looks something like this:
<div hidden id="S:0">
<template id="P:1"></template>
</div>
If you look at the response of the page, after 5 seconds you can see new content in the HTML streaming in. List of the posts is loaded, along with the inline script tag which contains a code which puts the streamed content in the placeholder.
Server vs Client Components
If you need interactivity in your component, you will need to use Client components. You can turn a component into a Client Component only by explicitly adding:
"use client";
at the top of the file.
Lets add a new component:
// components/client-button.tsx
'use client';
export default function ClientButton() {
return <button onClick={() => alert('Content of the alert!')}>Click</button>;
}
And then use the component in the homepage:
// app/page.tsx
import ClientButton from '../components/client-button';
export default function HomePage() {
return (
<div>
<h1>Home</h1>
<ClientButton />
</div>
);
}
Source of the page will now look like this:
<main>
<div>
<h1>Home</h1>
<button>Click</button>
</div>
</main>
Based on this we can see that the client components are prerendered.
Also, you will notice that now there is a new JS chunk that contains the newly added client component:
In the embedded RSC payload, you will see an entry like this:
4:I[980,["974","static/chunks/app/page-56a6f7a27071f365.js"],"default"]
And this is how hydration works using the RSC payload:
The server rendered
<button>Click</button>in the HTML.The RSC payload told React which client component is behind that HTML and which JS chunk it needs.
When the JS chunk for that module loads, React attaches event listeners (e.g.
onClick) and turns that static button into a interactive component.
Resources:
https://vercel.com/blog/understanding-react-server-components
https://medium.com/@z22857744/next-js13-full-route-cache-and-router-cache-aa060e6aeedb
https://blog.webdevsimplified.com/2024-01/next-js-app-router-cache/
https://nextjs.org/learn/dashboard-app/static-and-dynamic-rendering
https://tonyalicea.dev/blog/understanding-react-server-components/
https://edspencer.net/2024/7/1/decoding-react-server-component-payloads
https://web.dev/learn/performance/understanding-the-critical-path



