The Mental Shift: Pages Router vs App Router
After building three production applications with the App Router, the single biggest mental shift is this: your components are server-first by default. This is the opposite of everything we learned with the Pages Router.
In the Pages Router, everything was client-side React by default, and you opted into server-side with getServerSideProps or getStaticProps. In the App Router, components run on the server by default, and you opt into client-side behavior with 'use client'.
Lesson 1: Think in Layers, Not in Pages
The App Router encourages a "layered" mental model. Your route can have a layout.tsx, a page.tsx, loading.tsx, error.tsx, and more — each handling a different concern.
app/
├── layout.tsx // Root layout — runs once
├── page.tsx // Home page
├── loading.tsx // Loading UI (Suspense boundary)
├── error.tsx // Error boundary
├── dashboard/
│ ├── layout.tsx // Dashboard layout (nested)
│ ├── page.tsx // Dashboard page
│ └── [userId]/
│ └── page.tsx // Dynamic user page
I made the mistake early on of putting too much in the page component. The right approach: page components should be thin. They orchestrate data fetching and pass data down to components.
Lesson 2: Server Components for Data, Client Components for Interaction
The rule I now follow is simple:
- If a component fetches data → Server Component
- If a component uses
useState,useEffect, or event handlers → Client Component - If a component does both → split it into a server wrapper + client child
// ✅ Good pattern: Server wrapper, client child
// UserProfile.tsx (Server Component)
async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } });
return ; // pass data down
}
// UserProfileClient.tsx
'use client';
function UserProfileClient({ user }: { user: User }) {
const [editing, setEditing] = useState(false);
// ... interactive UI
}
Lesson 3: Route Handlers Replace API Routes (Mostly)
Route handlers in app/api/*/route.ts replace pages/api/*.ts. The syntax is cleaner and supports streaming responses natively.
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid data' }, { status: 400 });
}
// Process the validated data
const { name, email, message } = parsed.data;
// Store in DB, send email, etc.
return NextResponse.json({ success: true });
}
When to Use Server Actions Instead
For mutations triggered directly from forms (create, update, delete), Server Actions are often cleaner than API routes. They eliminate the need for a separate endpoint and work natively with React's form model.
// actions/contact.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function submitContact(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// Validate with Zod, save to DB
await db.contact.create({ data: { name, email } });
revalidatePath('/contact');
}
Lesson 4: Parallel Routes and Intercepting Routes
This is an underused feature. Parallel routes (@slot folders) let you render multiple pages in the same layout simultaneously — perfect for modals, split views, and dashboards.
// app/dashboard/@stats/page.tsx → renders in @stats slot
// app/dashboard/@feed/page.tsx → renders in @feed slot
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
stats,
feed,
}: {
children: React.ReactNode;
stats: React.ReactNode;
feed: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3">
<main className="col-span-2">{children}</main>
<aside>{stats}{feed}</aside>
</div>
);
}
Lesson 5: Caching is Powerful but Surprising
Next.js 15 changed the default caching behavior significantly. In Next.js 13-14, fetch was cached by default. In Next.js 15, it's not cached by default.
My current caching strategy:
- Static data that rarely changes:
cache: 'force-cache'orrevalidate: 3600 - User-specific data: always dynamic (
no-store) - Frequently updated public data:
revalidate: 60(ISR)
// Static: fetch once and cache indefinitely
const data = await fetch(url, { cache: 'force-cache' });
// ISR: revalidate every 60 seconds
const data = await fetch(url, { next: { revalidate: 60 } });
// Dynamic: never cache
const data = await fetch(url, { cache: 'no-store' });
Performance Wins I Implemented
- Dynamic imports for heavy client components (charts, animations, rich text editors)
- next/image for all images — automatic WebP conversion, lazy loading, blur placeholders
- React Suspense with meaningful loading skeletons rather than full-page spinners
- Server Components for content-heavy sections — they send HTML, not JavaScript bundles
💡 The App Router rewards thinking in terms of where code runs. Server = fast, secure, no bundle cost. Client = interactive, stateful. The most performant apps minimize the client/server boundary crossings.
Verdict After 3 Projects
The App Router is genuinely better for production applications. The learning curve is real — especially around caching and the mental model of server/client components. But once it clicks, you ship faster and the architecture is cleaner.
My recommendation: build one small project end-to-end with the App Router. Don't just read about it. The patterns become obvious very quickly once you're writing actual code.