Skip to content
·7 min read

Next.js App Router Patterns That Actually Scale (After 3 Production Apps)

App Router shipped in 2023. Three years later, most teams still use it like Pages Router with extra folders. Here's what actually scales past the demo.

Next.jsReactApp RouterWeb Dev

App Router shipped in Next.js 13. Three years and three production apps later, most teams still use it like Pages Router with extra folders. They add "use client" to everything, write API routes for every form, and wonder why their bundle is 800KB.

The win of App Router isn't the folders. It's Server Components, layouts that don't re-render, and Server Actions that replace half your API routes. Here's what actually scales, after shipping this on real client projects.

Default to Server Components, opt out, don't opt in

The biggest mental shift: every component is a Server Component by default. You don't add anything. You opt out with "use client" only when you need interactivity.

// app/products/page.tsx
// No directive. This is a Server Component.
 
import { db } from '@/lib/db'
import { ProductGrid } from './ProductGrid'
import { Filters } from './Filters'
 
export default async function ProductsPage() {
  const products = await db.product.findMany()
  return (
    <>
      <Filters />           {/* Client Component: interactive */}
      <ProductGrid products={products} />  {/* Server Component */}
    </>
  )
}

The pattern: a Server Component tree with Client Component leaves. The leaves handle clicks and state. The tree handles data fetching and rendering HTML.

The mistake: slapping "use client" on the page or a layout. That forces every child to be a Client Component, ships the database library to the browser, and your bundle balloons.

Layouts for state that survives navigation

In Pages Router, navigating between pages remounts everything. App Router layouts don't re-render on navigation, that's the superpower.

// app/(dashboard)/layout.tsx
'use client'
 
import { useState } from 'react'
import { Sidebar } from './Sidebar'
 
export function DashboardLayout({ children }) {
  const [sidebarOpen, setSidebarOpen] = useState(true)
  return (
    <div>
      <Sidebar open={sidebarOpen} onToggle={setSidebarOpen} />
      <main>{children}</main>
    </div>
  )
}

Navigate from /dashboard/projects to /dashboard/tasks and sidebarOpen survives. No context provider needed. No redux. Just put the state in the layout.

This is the right place for: auth state, theme toggles, sidebar/panel state, current organization.

Server Actions replace most API routes

In Pages Router, every form needs an API route. In App Router, use a Server Action.

// app/contact/page.tsx
import { revalidatePath } from 'next/cache'
 
async function handleSubmit(formData: FormData) {
  'use server'
  const name = formData.get('name')
  const email = formData.get('email')
  await db.contact.create({ data: { name, email } })
  revalidatePath('/admin/contacts')
}
 
export default function ContactPage() {
  return (
    <form action={handleSubmit}>
      <input name="name" />
      <input name="email" type="email" />
      <button type="submit">Send</button>
    </form>
  )
}

No onSubmit, no fetch('/api/contact'), no JSON parsing. The form posts directly to a server-side function. Types flow end-to-end. revalidatePath tells Next to re-render any cached page that depends on this data.

When to still use API routes:

  • Webhooks (Stripe, GitHub, etc.), they need a stable URL
  • External API integrations called from client code
  • Public APIs consumed by other apps

For internal mutations from your own UI, Server Actions win.

Parallel routes, for dashboards only

Parallel routes let you render multiple pages into one layout.

// app/dashboard/@analytics/layout.tsx
// app/dashboard/@team/layout.tsx
// app/dashboard/layout.tsx
 
export default function DashboardLayout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      <main>{children}</main>
      <aside>{analytics}</aside>
      <aside>{team}</aside>
    </div>
  )
}

Use cases:

  • Dashboards, multiple independent panels
  • Master-detail views, list on the left, detail on the right
  • Modal + content, intercepted route in a slot renders as a modal

Skip parallel routes for:

  • Marketing pages (just use sections)
  • Content sites (use sub-routes)
  • Anything where one route = one view

They add real complexity. Reach for them when your layout genuinely needs parallel state.

Stream slow content with Suspense

This is the easiest perceived-performance win in App Router.

// app/page.tsx
import { Suspense } from 'react'
 
export default function Page() {
  return (
    <>
      <Hero />                        {/* Renders instantly */}
      <Suspense fallback={<Spinner />}>
        <SlowProductGrid />           {/* Streams in when ready */}
      </Suspense>
    </>
  )
}

SlowProductGrid is an async Server Component that hits the database. Without Suspense, the whole page waits. With Suspense, the hero renders immediately and the grid streams in when ready.

For the user: the page loads in 200ms instead of 2s. The content fills in. They don't notice the gap because they're already reading the hero.

This is the single biggest LCP improvement you can make on data-heavy pages.

The patterns I avoid

Avoid: "use client" on layouts. This forces every child into Client Component land. Push interactivity down to the leaves.

Avoid: API routes for form submissions. Use Server Actions. The types flow, the cache revalidates, the code is shorter.

Avoid: parallel routes for marketing pages. They're for dashboards. Using them for "neat URL structure" is over-engineering.

Avoid: useEffect for data fetching. Fetch in the Server Component. Pass down as props. The client doesn't need to know how the data got there.

The workflow I use

  1. Start every component as a Server Component. Don't add a directive.
  2. Add "use client" only when I need useState, useEffect, or event handlers.
  3. Fetch data in Server Components. Pass as props.
  4. Use layouts for state that should survive navigation.
  5. Use Server Actions for mutations from forms.
  6. Wrap slow queries in <Suspense> with a fallback.
  7. Reach for parallel routes only when the layout genuinely has parallel state.

The mental shift is simple: the server is the default. The browser is the special case. Most teams get this backwards and ship a Pages Router app in App Router clothing.

App Router's biggest feature isn't the folders. It's that most of your code never reaches the browser. Ship less JavaScript, fetch data on the server, and let layouts hold the state that used to need Redux.

Want a Next.js app that scales?

I build Next.js apps with App Router done right, Server Components, Server Actions, streaming, and bundle sizes that don't grow with every feature. Let's talk.