Skip to content
All articles

4 min read

Deep Dive: How the Next.js App Router Actually Works

A comprehensive guide to the architecture and mental model behind the Next.js App Router

  • frontend
  • nextjs
  • architecture

The Paradigm Shift

Moving from the Pages directory to the App Router in Next.js feels like learning React all over again for the first time.

Just create a page.tsx and you are good to go, right?

If you are coming from the standard React SPA world, the flow usually looks like this:

Client requests page -> Server sends empty HTML + huge JS bundle -> Browser executes JS -> API calls happen -> UI appears.

In the App Router, No.

It's actually inverted. What you are doing is receiving fully formed HTML from the server first.

So what is the fundamental difference?

The difference is -

  • Components are Server-first by default
  • Layouts are nested and persistent
  • Navigation behaves differently (Soft vs Hard)
  • The Network tab looks completely different (The RSC Payload)

The Foundation

Server Components by Default

Let's start with the biggest change.

I've built a simple dashboard where users see their profile and a list of settings.

In the old world (Pages router or standard React), this would be a Client Component.

Here's what the mental model usually looks like:

// This is how we used to think
import { useEffect, useState } from 'react';

export default function Dashboard() {
  // Client side logic immediately
  const [data, setData] = useState(null);

  useEffect(() => {
    // Fetching happens on the browser
  }, []);

  return <div>...</div>;
}

In the App Router, this page.tsx is never sent to the browser as JavaScript.

It runs entirely on the server.

// app/dashboard/page.tsx
import db from '@/lib/db';

// This component runs ONLY on the server
export default async function Dashboard() {
  const data = await db.user.findFirst();

  // Only the resulting HTML and JSON data is sent to the client
  return <div>Hello {data.name}</div>;
}

This approach:

  • Reduces bundle size (dependencies used here don't go to the client)
  • Direct Database access (no API route needed for GETs)
  • Improved First Contentful Paint (FCP)

The Client Boundary

A common mistake is thinking 'use client' makes a component "Client Side Only".

It actually marks a boundary.

When you add 'use client' to a file, you are telling Next.js:

"From this point down the tree, allow React hooks and event listeners."

// components/interactive-button.tsx
'use client';

import { useState } from 'react';

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

// components/interactive-button.tsx

export default function LikeButton() {
  // We can use state here because of the directive above
  const [likes, setLikes] = useState(0);

  return <button onClick={() => setLikes((l) => l + 1)}>{likes}</button>;
}

Crucially: A Client Component is still pre-rendered on the server to generate the initial HTML. It is then "hydrated" on the client.

The Architecture

Nested Layouts & Persistence

The App Router introduces a file-system based router that focuses on nested layouts.

If I navigate from /settings/profile to /settings/account, the root layout and the settings layout do not re-render. They persist. Only the page segment changes.

Here is the structure:

app/
├── layout.tsx      # Root Layout (html/body tags)
├── page.tsx        # Home Page
└── settings/
    ├── layout.tsx  # Settings Sidebar (Persists on nav)
    ├── page.tsx    # /settings
    └── profile/
        └── page.tsx # /settings/profile

This persistence is huge for performance because React doesn't need to diff or rebuild the sidebar or header when you switch sub-pages.

The Router Cache and Navigation

This is where developers often get confused. "Why isn't my data updating when I click a link?"

Next.js uses an aggressive Client-side Router Cache.

The Soft Navigation

When you click a <Link href="/about" />:

  1. Next.js does not fetch a full HTML document.
  2. It fetches the RSC Payload (a special JSON-like format).
  3. It merges this payload into the current DOM.

This is called a Soft Navigation. The browser state is preserved.

If you open your Network tab and filter by Fetch/XHR while navigating, you won't see HTML. You will see a text stream that looks like this:

1:I["module-id",["css-id"],""]
2:"$Sreact.suspense"
...

This is the server telling the client tree how to update without a full reload.

Solution: Managing the Cache

If you need data to refresh immediately upon navigation (for example, after a mutation), you cannot rely on standard Link behavior alone if the data is dynamic.

You have two main levers:

  1. Route Segment Config

Force the page to be dynamic on the server.

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // Opt out of static caching
  1. On-demand Revalidation

When performing an action, tell Next.js to purge the cache.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function updateUser() {
  await db.user.update(...);

  // This tells the Router Cache: "The data for this path is stale"
  revalidatePath('/dashboard');
}

Special Files

The App Router relies on specific file conventions to handle UI states automatically. You don't need to manage loading states manually in your components.

// app/dashboard/loading.tsx
export default function Loading() {
  return <div className="skeleton-loader">Loading Dashboard...</div>;
}

Next.js automatically wraps your page.tsx in a <Suspense> boundary using this component as the fallback.

Similarly:

  • error.tsx: Wraps in an Error Boundary
  • not-found.tsx: Handles 404s
  • layout.tsx: Wraps the children

Summary

  1. Think Server-First: Components are server-side by default; verify logic before adding 'use client'.
  2. Understanding Boundaries: 'use client' is the bridge between server logic and browser interactivity.
  3. Layout Persistence: Layouts do not re-render on navigation, which saves performance but requires understanding for state management.
  4. The RSC Payload: Navigation is a soft merge of server data into the client DOM, not a hard page load.
  5. Caching is Aggressive: Learn revalidatePath and dynamic configs to control data freshness.
  6. File Conventions: Use loading.tsx and error.tsx to handle UI states declaratively.