Next.js has been one of the most popular frameworks for React-based projects for many years, but the App Router introduced in version thirteen radically reshaped its entire architecture. If you previously worked with the Pages Router, the new approach may feel unfamiliar at first, because it rethinks not only the folder structure but the very philosophy of how data is loaded and how pages are rendered. In this article we will explore the core ideas behind the App Router, how it differs from the old approach, and each capability through real code examples, so that you understand clearly which approach to choose in your own project.
The fundamental difference between Pages Router and App Router
In the old Pages Router every file lived inside the pages folder and turned directly into a component that ran in the browser. To load data you had to export special functions such as getServerSideProps or getStaticProps, which artificially separated component logic from data logic. The App Router, by contrast, is built around the app folder, where components are Server Components by default, meaning they run only on the server and do not ship any JavaScript to the browser. This difference is extremely important because it directly affects page load speed, SEO metrics and the overall experience users have with your site.
Another strength of the App Router is that it lets you manage layout, loading and error states naturally through the file system. In the Pages Router you had to handle such states manually, whereas the App Router automatically intercepts them using specially named files. Below we examine each of these mechanisms separately and see just how much they simplify everyday development work.
Server Components โ the new default approach
Any component you create in the App Router is treated as a Server Component unless you explicitly state otherwise. This means the component is rendered on the server, its code never reaches the browser at all, and as a result the JavaScript size of the application drops significantly. Server Components can access a database directly, use secret API keys and pull in heavy libraries without loading them into the client browser. If you do need interactivity, that is useState, useEffect or event handling, you add the 'use client' directive at the very top of the file and it becomes a Client Component.
// app/products/page.tsx โ this is a Server Component (default)
async function getProducts() {
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}Notice that the component itself is declared as async. This is one of the most pleasant features of the App Router: a Server Component can be directly asynchronous and use await. You no longer need separate data fetching functions; you load the data right inside the component, and this code never reaches the browser.
File-based routing: page and layout
In the App Router a route is defined by each folder, and the page.tsx file inside a folder represents the actual page for that route. For example, the file app/blog/page.tsx maps to the /blog address, while app/blog/[slug]/page.tsx maps to a dynamic address such as /blog/some-article. In addition, the layout.tsx file provides a shared wrapper for all pages in its folder and any nested ones, such as a navigation bar or a footer. A layout is not re-rendered when you move between pages, which is very useful for preserving state and improving performance.
// app/blog/layout.tsx
export default function BlogLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<nav>Blog navigation</nav>
{children}
</section>
)
}Thanks to this structure the general skeleton of the application and the page-specific part are clearly separated. Layouts can be nested inside one another, meaning the root layout wraps the whole application and then each section can have its own layout, which creates an extremely flexible interface hierarchy that scales well as the project grows. This file-based approach continues into the handling of loading and error states as well.
The App Router offers a special loading.tsx file to show the user something while data is loading. You simply add a file with that name to a folder, and Next.js automatically displays the loading state while the page there is waiting for its data. Likewise, the error.tsx file triggers when an error occurs during rendering, and it must be a Client Component, because it catches the error and provides the ability to retry.
// app/dashboard/loading.tsx
export default function Loading() {
return <p>Loading data...</p>
}
// app/dashboard/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<p>An error occurred: {error.message}</p>
<button onClick={() => reset()}>Retry</button>
</div>
)
}This mechanism is built on the Suspense and Error Boundary concepts from React, but the App Router wraps them automatically through the file system. As a result you get a professional loading and error handling experience without writing a single line of boilerplate, simply by creating the right files in the right places.
Server Actions โ forms and mutations
Server Actions are one of the most revolutionary capabilities of the App Router, allowing you to call server-side functions directly from inside a component without creating a separate API endpoint. You add the 'use server' directive at the top of a function and trigger it through a form or a button click. This is excellent for saving, updating or deleting data, because all the logic stays on the server and no extra code is shipped to the client.
// app/contact/page.tsx
export default function ContactPage() {
async function submit(formData: FormData) {
'use server'
const name = formData.get('name')
await saveToDatabase({ name })
}
return (
<form action={submit}>
<input name="name" />
<button type="submit">Submit</button>
</form>
)
}This approach greatly smooths the boundary between the frontend and the backend. You no longer need to manually write REST or GraphQL endpoints, manage fetch calls and handle errors over and over again; you simply write a server function and bind it to a form, which noticeably speeds up development.
Rendering strategies: SSR, SSG and ISR
The App Router supports three main rendering strategies, and choosing between them depends on how often your data changes. Static Site Generation (SSG) creates the page once at build time and runs the fastest, which is ideal for blogs or documentation. Server-Side Rendering (SSR) re-renders the page on the server on every request, which suits cases where fresh data is always required. Incremental Static Regeneration (ISR) finds a middle ground: the page is generated statically but is refreshed in the background after a defined interval.
// SSG โ default, fetch is cached
await fetch('https://api.example.com/data')
// SSR โ fresh data on every request
await fetch(url, { cache: 'no-store' })
// ISR โ refreshed every 60 seconds
await fetch(url, { next: { revalidate: 60 } })As you can see, in the App Router the rendering strategy is controlled through the options of the fetch call, which gives you very fine and precise control. You can even make some data static and other data dynamic within the same page, something that was practically impossible in the Pages Router. Overall the App Router is a powerful, flexible and performant foundation for modern web applications, and the time spent learning it definitely pays off. If you are starting a new project, choosing the App Router will be the most correct long-term decision.