Next.js Signal vs Noise React Web Architecture

Next.js App Router: Two Years Later

Static Signal
A steampunk clockface with brass gears and purple glass panels representing the passage of time with Next.js App Router

When Next.js 13 launched the App Router in late 2022, the reaction in the dev community was somewhere between excited and deeply skeptical. A new routing paradigm, React Server Components baked in by default, a new mental model for data fetching, and a migration path that ranged from “fine, actually” to “we rewrote everything and lost two sprints.”

Two years and several production projects later: what held up, what didn’t, and would I recommend it to someone starting a new project today?

The short answer is yes — with a clear-eyed understanding of what you’re signing up for.


What the App Router Actually Changed

It’s worth being precise about what the App Router is and isn’t, because a lot of the confusion in 2023 came from people conflating several different things that shipped simultaneously.

The routing system itself changed from a flat pages/ directory to a nested app/ directory where folders define routes and special files (page.tsx, layout.tsx, loading.tsx, error.tsx) control behavior at each level. This part is genuinely better. Nested layouts that persist across navigations without re-mounting, colocation of route-specific components with their routes, and a file-based convention for loading and error states — these are improvements that are hard to argue with once you’ve used them.

React Server Components (RSC) are the more contentious part. Components in the app/ directory are server components by default. They run only on the server, can directly access databases or APIs without an extra API route, and don’t ship any JavaScript to the client. To get client-side interactivity, you opt in with "use client" at the top of the file.

These two things — new routing and RSC — arrived together, which made the learning curve steeper than it needed to be. You weren’t just learning a new file structure. You were learning a new component model with different rules about where state lives, what hooks are available where, and how data flows through a tree that’s partly server, partly client.


Where It Actually Delivers

Layouts are the killer feature, full stop. The Pages Router had _app.tsx and _document.tsx, which worked fine until you needed different layouts for different sections of the app. Pulling that off cleanly required passing props through the page component or reaching for a layout pattern that was always slightly awkward.

With the App Router, you nest a layout.tsx at any route segment and it wraps everything below it. Authenticated routes get their auth layout. Marketing pages get their marketing shell. The dashboard gets its sidebar. None of them interfere with each other. This alone was worth the migration cost on two projects.

Server Components genuinely reduce client-side JavaScript. The pitch was real. A data-fetching component that runs on the server, renders to HTML, and ships zero bytes of JS to the browser — that’s a meaningful win for performance. Your async/await database calls or API fetches live right in the component. No useEffect, no loading state boilerplate, no API route that exists only to proxy data to the frontend.

// This runs on the server. No useEffect. No fetch wrapper. No loading state.
export default async function PostList() {
  const posts = await db.query('SELECT * FROM posts ORDER BY created_at DESC')

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

The component above ships no JavaScript. It’s HTML by the time it reaches the browser. On content-heavy sites — blogs, documentation, marketing pages — this matters.

Streaming and Suspense work the way they were supposed to. Wrap a slow component in <Suspense> and Next.js streams it — the shell of the page arrives immediately, the slow part fills in when it’s ready. This is the right model for pages with mixed fast/slow data. In the Pages Router you were effectively blocked waiting for the slowest getServerSideProps call before anything reached the user.


Where It Still Creates Friction

The "use client" boundary is a footgun. The rule sounds simple: if you need hooks or browser APIs, mark the component as a client component. In practice, the boundary propagates in ways that catch you off guard.

If you put "use client" on a parent component, every child in its tree becomes a client component too — even ones that didn’t need to be. The correct pattern is to push the "use client" boundary as far down the tree as possible, isolating the interactive parts. That takes discipline, and it requires thinking about your component hierarchy in a way that the Pages Router never did.

There’s also the prop serialization constraint: you can’t pass non-serializable values (functions, class instances, anything that can’t cross a server/client boundary) from server to client components as props. This trips people up regularly when they try to pass callbacks down from a server layout into a client component.

Caching semantics are still confusing. Next.js 13 and 14 introduced a layered caching model — request memoization, the Data Cache, the Full Route Cache, and the Router Cache — and the defaults were aggressive in ways that surprised developers. Fetching the same URL twice in one render? Automatically deduplicated. Page not updating after a mutation? Probably a stale cache. Opting out requires understanding which layer is caching what and applying the right cache directive.

Vercel has acknowledged this and Next.js 15 dialed back some of the defaults, making fetch requests uncached by default in dynamic routes. But the mental model for caching in the App Router still requires more deliberate thought than it did in the Pages Router, where getServerSideProps was bluntly “always fresh” and getStaticProps was bluntly “built at build time.”

The Pages Router coexistence story is awkward. If you’re migrating an existing app rather than starting fresh, Next.js lets both routers coexist — you can have an app/ and a pages/ directory running side by side. In theory this makes incremental migration possible. In practice it means you’re running two routing systems, two data fetching paradigms, and two mental models simultaneously. Teams that tried to migrate one route at a time often found the friction of switching contexts more expensive than expected.

The clean migration path is to rewrite entire sections at once. That’s a real cost.


The Server Actions Situation

Server Actions arrived in Next.js 14 and completed the picture in a way that made the App Router feel genuinely cohesive for the first time.

The premise: instead of writing a POST /api/something route to handle a form submission or mutation, you write a function marked with "use server" and call it directly from your component. Next.js handles the network boundary.

async function createPost(formData: FormData) {
  'use server'
  const title = formData.get('title') as string
  await db.insert({ title })
  revalidatePath('/posts')
}

export default function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  )
}

This is a meaningfully simpler model for form handling. The mutation, the cache invalidation, and the redirect all live together. No separate API route, no fetch call, no manual state management for pending/error states (though you’ll reach for useFormStatus or useActionState for those).

The concern I hear most is “isn’t this just PHP?” — the implication being that mixing server logic with UI components is an architectural step backwards. I don’t buy it. The boundary is explicit and the serialization rules prevent the worst patterns. It’s closer to a well-typed RPC layer than it is to <?php echo $_POST['name'] ?>.


Honest Performance Reality

The marketing around Server Components leans heavily on the JavaScript bundle reduction angle. It’s real, but it’s not the whole picture.

Server Components reduce the amount of JS that runs in the browser. They don’t necessarily reduce the time to first byte. If your server component is making slow database queries or calling a sluggish API, the HTML takes that long to arrive — and partial streaming only helps if your slow content is wrapped in a Suspense boundary and genuinely independent of the fast content.

The wins are most pronounced when:

  • You have content-heavy pages with minimal interactivity
  • You’re replacing client-side data fetching (useEffect + fetch) with server-side fetching
  • Your bundle was bloated with libraries that were only needed for server-side rendering

The wins are minimal when:

  • Your page is highly interactive (the client JS cost is unavoidable)
  • Your bottleneck is server response time or database latency
  • You were already doing efficient SSR with getServerSideProps

In other words, Server Components are not a performance silver bullet. They’re a meaningful tool for the right problems.


Starting Fresh vs. Migrating

If you’re starting a new Next.js project today, use the App Router. The ergonomics have matured, the ecosystem has caught up (most major libraries now support RSC properly), and the Pages Router is in maintenance mode. Vercel isn’t developing new features for it. New documentation is App Router first.

The learning curve is real but finite. Spend a day understanding the server/client boundary, understand the Suspense model, and the rest follows.

If you’re migrating an existing Pages Router app: be honest about the cost. For a small app with a handful of routes, an incremental migration is achievable. For a large app with complex data fetching patterns, deeply nested contexts, and shared state — budget real time for it. Don’t underestimate the cognitive cost of running both systems in parallel.

The trigger I’d use: if you’re adding a major new section to your app, build it in the App Router. If you’re in maintenance mode on an existing app with no major new sections planned, the migration cost likely isn’t worth it.


The Verdict

Two years in, the App Router is the right foundation for new Next.js projects. Nested layouts, streaming, Server Components for data-heavy content, and Server Actions for mutations — when these things click together, the result is cleaner code and a faster baseline than the Pages Router delivered.

The caching model is still rougher than it should be. The "use client" boundary requires more upfront design thinking. The migration story for existing apps is genuinely difficult.

But the direction is right. The Pages Router’s data fetching model — getStaticProps, getServerSideProps, getInitialProps — was always a set of workarounds. The App Router is a coherent model built around what React is actually trying to be.

If you’ve been avoiding it because of the 2023 discourse, it’s worth giving it a fresh look. The rough edges that caused the loudest complaints have mostly been sanded down. What’s left is a framework that, for most use cases, makes you write less code to get a faster result.

That’s the bar. The App Router clears it.