Next.js7 min readMay 1, 2025

Next.js 15 App Router: Real Lessons from Building Production Apps

Practical lessons from building multiple Next.js 15 applications — server components vs client components, data fetching patterns, route handlers, and performance optimization tips.

Next.jsReactPerformanceTypeScript

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' or revalidate: 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.