Ryan Youngs
← All posts

Understanding the Next.js App Router: A Practical Guide


title: "Understanding the Next.js App Router: A Practical Guide" date: "2026-05-19" description: "A hands-on look at how the Next.js App Router works, why params became a Promise, and how to avoid the pitfalls that trip up developers migrating from the Pages Router."

The Next.js App Router introduced in version 13 changed how we think about routing, data fetching, and component rendering. If you have been building with the Pages Router, the conceptual shift is real — but the benefits are worth it.

What Changed and Why It Matters

The most important mental model shift is this: almost everything in the App Router is a Server Component by default. That single decision cascades through how you fetch data, how you handle state, and how you structure your components.

Under the Pages Router, getServerSideProps and getStaticProps were special lifecycle functions bolted onto your page component. In the App Router, data fetching is just async/await inside a component — no magic API, no prop drilling from a lifecycle function.

// Pages Router — old pattern
export async function getStaticProps() {
  const posts = await fetchPosts()
  return { props: { posts } }
}
 
// App Router — just async components
export default async function BlogPage() {
  const posts = getAllPosts()  // runs at build time, no API needed
  return <PostList posts={posts} />
}

The params-as-Promise Shift

One change that catches every developer migrating from older Next.js is how dynamic route parameters are passed. In Next.js 15+, params is a Promise<{ slug: string }>, not a plain object.

This code will fail silently at runtime:

// WRONG — params is a Promise, not an object
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params  // slug is undefined at runtime
}

The correct pattern awaits params explicitly:

// CORRECT — always await params in Next.js 15+
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  // now slug is a real string
}

This was changed to support streaming and deferred segment resolution. The TypeScript types enforce it — if you type params as the old { slug: string } shape, you get a type error rather than a silent runtime bug.

Static Generation Still Works — And It Is Fast

One concern developers have when moving to Server Components is whether static generation still applies. It does. Use generateStaticParams to enumerate all slugs at build time:

export async function generateStaticParams() {
  return getAllPosts().map((post) => ({ slug: post.slug }))
}
 
export const dynamicParams = false  // unknown slugs → 404, not 500

The dynamicParams = false export is important. Without it, a request for a slug that was not in generateStaticParams will attempt a dynamic render and likely throw a module-not-found error instead of cleanly returning 404.

A Practical Checklist

When building a new App Router page, I work through this list:

  1. Does this page read data? Make the component async.
  2. Does it use browser APIs (localStorage, window, event handlers)? Add 'use client'.
  3. Is this a dynamic route? Type params as Promise<{ ... }> and await it.
  4. Do I want 404 on unknown params? Export dynamicParams = false.
  5. Am I mixing Server and Client Components? Keep Client Components at the leaves of the tree.

The key concepts to keep in mind throughout:

"Server Components are not a replacement for Client Components — they are a complement. Use each where it fits." — a reasonable rule of thumb


The App Router is genuinely better for content-heavy sites. Once the mental model clicks — Server Components own data fetching, Client Components own browser state — the architecture becomes straightforward. The official Next.js docs are worth reading cover-to-cover if you have not already.