Next.js App Router: The Guide I Wish I Had When I Migrated from Pages Router
It was a Tuesday at 11pm and I had a client's shopping cart broken in production. The problem: I'd migrated to App Router by following the official docs like an IKEA manual — methodically, with blind faith — and completely forgot to understand why things worked the way they did. When something broke, I had no idea where to even look.
This is the guide I wish I had. Not Vercel's. Mine.
The mental shift nobody tells you that you need
The biggest mistake I made was treating App Router like Pages Router with different folders. It's not. It's a completely different paradigm.
In Pages Router, every component is a Client Component by default. You can use useState, useEffect, server-side fetch with getServerSideProps — but it's all explicit, separated, tidy.
In App Router, every component is a Server Component by default. That means it runs on the server, never hits the client bundle, can talk directly to your database, and has zero access to window, localStorage, or React hooks.
When I migrated my first project, I spent three hours debugging this:
// app/dashboard/page.tsx
export default function Dashboard() {
const [count, setCount] = useState(0) // 💥 ERROR
// TypeError: useState is not a function
return <div>{count}</div>
}
The fix isn't "go back to Pages Router." The fix is understanding when you actually need interactivity and explicitly marking that component:
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Clicks: {count}
</button>
)
}
The rule I'd tattoo on my hand: Server Components by default, Client Components only when you need interactivity, browser state, or DOM events.
The folder structure that actually works
App Router lives in /app. Every folder with a page.tsx becomes a route. But there are special files that change everything:
app/
├── layout.tsx ← Root layout (required)
├── page.tsx ← Route /
├── loading.tsx ← Loading UI (streaming)
├── error.tsx ← Error handling
├── not-found.tsx ← 404
├── dashboard/
│ ├── layout.tsx ← Nested layout
│ ├── page.tsx ← Route /dashboard
│ └── settings/
│ └── page.tsx ← Route /dashboard/settings
└── api/
└── webhook/
└── route.ts ← API Route
Nested layouts are the most powerful thing here, and also the most confusing. The layout.tsx in a folder wraps all its children without unmounting when you navigate between sub-routes. This is exactly what we always wanted and never had cleanly in Pages Router.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<Sidebar /> {/* Renders ONCE, doesn't get destroyed on navigation */}
<main className="flex-1">{children}</main>
</div>
)
}
Data fetching: where almost everyone screws up
Forget getServerSideProps, getStaticProps, and getInitialProps. In App Router, you fetch data directly in the component:
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.yourdomain.com/products', {
next: { revalidate: 60 } // Revalidate every 60 seconds
})
if (!res.ok) throw new Error('Failed to load products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts() // async/await directly in the component
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
Yes, the component is async. Yes, it works. Yes, it felt weird to me at first too.
The cache system that cost me two hours
Next.js caches fetch by default. This is a feature, not a bug. But if you don't understand it, you'll lose your mind.
// Cached indefinitely (like getStaticProps)
const data = await fetch('/api/data')
// No cache (like getServerSideProps)
const data = await fetch('/api/data', { cache: 'no-store' })
// Time-based revalidation
const data = await fetch('/api/data', { next: { revalidate: 3600 } })
// Tag-based revalidation (on-demand)
const data = await fetch('/api/data', { next: { tags: ['products'] } })
That last one saved me when a client kept asking why updated prices weren't showing up. With tags you can invalidate the cache whenever data mutates:
// app/api/update-product/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const body = await request.json()
await updateProductInDB(body)
revalidateTag('products') // 🔥 Invalidates everything using this tag
return Response.json({ ok: true })
}
Streaming and Suspense: the magic that makes all the pain worth it
This is what made me fall in love with App Router — the thing that was flat-out impossible to do cleanly in Pages Router.
Streaming lets you send parts of the page to the browser while other parts are still being computed on the server. The user sees content fast instead of staring at a blank screen.
Combined with Suspense, it's incredible:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './UserStats' // Fast query
import { SalesChart } from './SalesChart' // Slow query (aggregates data)
import { RecentOrders } from './RecentOrders'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Renders almost instantly */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
{/* Arrives when it's ready, without blocking the rest */}
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
)
}
Each Suspense boundary resolves independently. If SalesChart takes 2 seconds and UserStats takes 200ms, the user sees the stats first and the chart pops in later. No client-side JavaScript. No useEffect. No manual loading state.
The loading.tsx file does exactly this at the route level:
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}
What breaks in production (my trauma list)
1. Cookies and headers in Server Components
If you need to read a cookie in a Server Component, don't touch document.cookie. Use Next.js's built-in functions:
import { cookies, headers } from 'next/headers'
export default async function Page() {
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')
const headersList = await headers()
const userAgent = headersList.get('user-agent')
return <div>Token: {token?.value}</div>
}
2. Passing functions as props to Client Components
This one broke my brain at first:
// ❌ You CANNOT pass a function from a Server Component to a Client Component
export default function ServerComponent() {
const handleClick = () => console.log('click')
return <ClientButton onClick={handleClick} /> // Runtime error
}
// ✅ The function has to live inside the Client Component
'use client'
export function ClientButton() {
const handleClick = () => console.log('click')
return <button onClick={handleClick}>Click</button>
}
The reason is simple: a JavaScript function can't be serialized to travel between server and client. It makes total sense when you think about it, but it really hurts when you discover it at 2am.
3. The navigation router changed
Forget useRouter from next/router. It's next/navigation now:
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export function NavComponent() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
return (
<button onClick={() => router.push('/dashboard')}>
Go to dashboard
</button>
)
}
Importing from next/router in App Router just doesn't work. It won't throw a clear error — it just breaks in mysterious ways.
4. Environment variables and the server
In Pages Router, you had NEXT_PUBLIC_ for the client and unprefixed for the server. That's still true, but with one important detail: in Server Components you can use server-side variables directly. In Client Components, only NEXT_PUBLIC_ ones.
// Server Component - totally fine
const secret = process.env.DATABASE_URL // ✅
// Client Component - NEVER do this
const secret = process.env.DATABASE_URL // undefined — and if it weren't undefined, you'd have a massive security leak
The incremental migration I actually recommend
Don't migrate everything at once. Next.js lets you have /pages and /app coexisting. The strategy that worked for me:
-
Layouts first — replace
_app.tsxand_document.tsxwith the rootlayout.tsx - Static pages next — the ones without complex data fetching
- Then pages with fetch — migrate data fetching to Server Components
- Authentication last — it's the most complex part, leave it for when you really understand the model
You can deploy each step. You can test each step. Don't try to do it all in one night before Monday.
Is it worth it?
Yes. Absolutely, unequivocally yes.
My before/after metrics on the e-commerce project: Time to First Byte dropped from 340ms to 89ms. Largest Contentful Paint went from 3.2s to 1.1s. The client-side JavaScript bundle shrank by 60% because most of the listing components are now Server Components.
The client has no idea what App Router is, but he messaged me to say the store "feels faster." That's worth every late night debugging session.
The learning curve is real and it hurts. But once you internalize the mental model — server by default, client by exception, data close to where it's consumed — writing React applications feels cleaner than it ever has.
I'm starting a new project right now and I can't imagine going back to Pages Router. It's like going back to jQuery after learning React. Technically it works. But you know something better exists.
Top comments (0)