Web DevelopmentNext.jsApp RouterReactServer Components

Next.js App Router: The Mental Model You Actually Need

Server components, layouts, and the paradigm shift from pages to the App Router — explained with real examples.

March 31, 2026

nextjs app router mental model

The App Router isn't just a new way to organize files. It's a different execution model, and if you try to understand it using your Pages Router mental model, you'll be confused for weeks. The components look the same. The file conventions are similar. But the runtime behavior is fundamentally different.

Here's the mental model that actually makes it click.

The Core Shift: Where Code Runs

In the Pages Router, everything runs in two places: the server (at build time or request time via getServerSideProps/getStaticProps) and the browser. The split was explicit — data fetching functions were separate from components.

In the App Router, the split is at the component level. Every component is either a Server Component or a Client Component. Server Components run only on the server. Client Components run on both server and client (they SSR then hydrate).

The default is Server Component. You opt into client-side behavior with 'use client'.

// This is a Server Component by default.
// It runs on the server. It never ships to the browser.
// You can use async/await, read environment variables, query databases directly.
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug) // direct DB query, not an API call
  return <article>{post.title}</article>
}
'use client'

// This is a Client Component.
// It SSRs (runs on server once) then hydrates in the browser.
// It can use useState, useEffect, event handlers.
import { useState } from 'react'

export function LikeButton({ postId }: { postId: number }) {
  const [liked, setLiked] = useState(false)
  return (
    <button onClick={() => setLiked(l => !l)}>
      {liked ? '❤️' : '🤍'}
    </button>
  )
}

The Rendering Tree

A page in the App Router is a tree of components. Server Components and Client Components can be mixed — with one important constraint: a Server Component cannot be imported inside a Client Component.

layout.tsx (Server)
└── page.tsx (Server)
    ├── PostHeader (Server) — fetches post data directly
    ├── PostBody (Server) — renders MDX
    └── CommentSection (Client) — handles user interaction
        └── CommentForm (Client) — form state, submissions

You can, however, pass Server Components as children to Client Components:

// ✅ This works — PostCard is passed as a prop, not imported
export function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [expanded, setExpanded] = useState(false)
  return (
    <div>
      <button onClick={() => setExpanded(e => !e)}>Toggle</button>
      {expanded && children}
    </div>
  )
}

// In the Server Component that composes them:
<ClientWrapper>
  <PostCard post={post} /> {/* PostCard can be a Server Component */}
</ClientWrapper>

This pattern — passing server content as children into client containers — is how you keep most of your tree as Server Components while adding interactivity where you need it.

File Conventions

The App Router uses a specific set of reserved files:

| File | Purpose | |------|---------| | layout.tsx | Wraps a route segment and its children. Persists across navigations. | | page.tsx | The UI for a route. Makes the segment publicly accessible. | | loading.tsx | Shown while page.tsx or its data fetches are loading (uses Suspense). | | error.tsx | Error boundary for a route segment. Must be a Client Component. | | not-found.tsx | Shown when notFound() is called inside the segment. | | template.tsx | Like layout but re-mounts on navigation (rare). |

Every folder in app/ is a route segment. A folder only becomes a publicly accessible URL when it has a page.tsx.

app/
├── layout.tsx          → wraps all routes
├── page.tsx            → /
├── blog/
│   ├── layout.tsx      → wraps /blog and all /blog/* routes
│   ├── page.tsx        → /blog
│   └── [slug]/
│       ├── page.tsx    → /blog/[slug]
│       └── loading.tsx → shown while /blog/[slug] data loads
└── api/
    └── comments/
        └── route.ts    → /api/comments (not a page, not accessible as a URL page)

Data Fetching: No More getServerSideProps

In Server Components, you fetch data directly:

// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
  params,
}: {
  params: { slug: string }
}) {
  // Fetch directly — this runs on the server
  const post = await getPostBySlug(params.slug)

  if (!post) {
    notFound() // triggers not-found.tsx
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <PostBody content={post.mdxContent} />
    </article>
  )
}

Next.js extends fetch to support caching:

// Cache for 60 seconds (ISR-style)
const res = await fetch(`${CMS_URL}/api/posts/${slug}`, {
  next: { revalidate: 60 },
})

// Cache until manually invalidated
const res = await fetch(`${CMS_URL}/api/posts/${slug}`, {
  next: { revalidate: false },
})

// No cache — always fresh
const res = await fetch(`${CMS_URL}/api/posts/${slug}`, {
  cache: 'no-store',
})

Metadata

Dynamic metadata replaces <Head> imports:

export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await getPostBySlug(params.slug)
  return {
    title: post?.title,
    description: post?.description,
    openGraph: {
      title: post?.title,
      description: post?.description,
      images: post?.featuredImage?.url ? [post.featuredImage.url] : [],
    },
  }
}

Common Mistakes

Adding 'use client' to everything. Start with Server Components. Only add 'use client' when the component actually needs browser APIs, state, or event handlers. The less JavaScript you ship to the browser, the faster your pages load.

Trying to use hooks in Server Components. useState, useEffect, useContext — none of these work in Server Components. If you're reaching for a hook, the component needs 'use client'.

Ignoring Suspense. loading.tsx is a Suspense boundary. Use it at every route segment that has a slow data fetch so users see a loading state instead of a blank page.

Key Takeaways

  • Every component is Server by default; add 'use client' only when you need interactivity
  • Server Components never ship to the browser — they can query databases, read env vars, and do async work directly
  • Pass server content as children to client components to keep the tree server-heavy
  • Fetch data directly in Server Components with fetch() — the next.revalidate option controls caching
  • layout.tsx persists across navigations; page.tsx is the visible UI for that URL