Next.js 15 App Router in Production: Patterns, Pitfalls, and Performance



Next.js 15 has been in production for over a year now, and the dust has settled on the App Router patterns that actually work. What started as a confusing paradigm shift — “where did getServerSideProps go?” — has matured into a powerful model for building fast, scalable web applications. But the learning curve is real, and the pitfalls are subtle. Here’s what I’ve learned from deploying it at scale.

Code on screen Photo by Lukas on Unsplash

The Rendering Model (Finally Explained Clearly)

The biggest source of confusion in the App Router is understanding where and when code executes. There are now four rendering contexts:

ContextRuns OnWhen
Server ComponentServerRequest time (or build time)
Client ComponentBrowserHydration + interactions
Route HandlerServerAPI requests
Server ActionServerForm submissions, mutations

The golden rule: components are Server Components by default. You opt into client-side behavior with "use client".

// app/products/page.tsx — Server Component (default)
// This runs on the server. You can:
// - await database queries directly
// - access environment variables
// - use Node.js APIs
// You CANNOT:
// - use useState, useEffect
// - attach event handlers
// - access browser APIs

import { db } from "@/lib/db";
import { ProductCard } from "./product-card";

export default async function ProductsPage() {
  // Direct DB call — no API needed!
  const products = await db.product.findMany({
    where: { active: true },
    orderBy: { createdAt: "desc" },
  });
  
  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  );
}
// app/products/product-card.tsx — Client Component
"use client";  // opt-in to client-side features

import { useState } from "react";
import { addToCart } from "@/actions/cart";

export function ProductCard({ product }) {
  const [loading, setLoading] = useState(false);
  
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button 
        onClick={async () => {
          setLoading(true);
          await addToCart(product.id);
          setLoading(false);
        }}
        disabled={loading}
      >
        {loading ? "Adding..." : "Add to Cart"}
      </button>
    </div>
  );
}

The Caching Hierarchy: Next.js 15’s Biggest Change

Next.js 15 overhauled the caching model from version 14. Understanding it is critical for correct behavior.

The Four Cache Layers

Request
  ↓
Router Cache (in-memory, client-side, 30s-5min)
  ↓
Full Route Cache (disk, server-side, static pages)
  ↓  
Data Cache (persistent, per fetch(), opt-in)
  ↓
Request Memoization (per-request deduplication)

The critical change in Next.js 15: fetch() requests are no longer cached by default. You must opt-in:

// Next.js 14: cached by default (surprised everyone)
const data = await fetch('https://api.example.com/data');

// Next.js 15: NOT cached by default (fixed the surprise)
const data = await fetch('https://api.example.com/data');
// Equivalent to: { cache: 'no-store' }

// To cache, opt-in explicitly:
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }  // revalidate every hour
});

// Or cache indefinitely (until manually invalidated):
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
});

Route Segment Config

Control caching at the route level:

// app/dashboard/page.tsx

// Dynamic page — no caching
export const dynamic = 'force-dynamic';

// Static page — full cache
export const dynamic = 'force-static';

// Revalidate every 60 seconds
export const revalidate = 60;

// Dynamic page that streams
export const dynamic = 'force-dynamic';
export const runtime = 'edge';  // runs at the edge

On-Demand Revalidation

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const { path, tag, secret } = await request.json();
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }
  
  if (path) {
    revalidatePath(path);
  }
  
  if (tag) {
    revalidateTag(tag);
  }
  
  return NextResponse.json({ revalidated: true });
}

Tag your fetches, then invalidate by tag from your CMS webhook:

const posts = await fetch('https://cms.example.com/posts', {
  next: { 
    tags: ['posts'],
    revalidate: 3600
  }
});

// When a post is published: POST /api/revalidate with { tag: 'posts' }

Streaming and Suspense: The Right Pattern

Streaming is Next.js 15’s killer feature for perceived performance. Instead of waiting for all data, stream the page progressively:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats } from './user-stats';
import { RecentOrders } from './recent-orders';
import { RevenueChart } from './revenue-chart';
import { Skeleton } from '@/components/ui/skeleton';

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-4">
      {/* Each Suspense boundary streams independently */}
      <Suspense fallback={<Skeleton className="h-32" />}>
        <UserStats />
      </Suspense>
      
      <Suspense fallback={<Skeleton className="h-32" />}>
        <RevenueChart />
      </Suspense>
      
      <Suspense fallback={<Skeleton className="h-64 col-span-2" />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}

Each component fetches its own data in parallel. The browser renders what’s ready immediately, and the rest streams in as it becomes available.

The anti-pattern: waterfall fetches in a parent component:

// ❌ WRONG: Sequential waterfall
export default async function Dashboard() {
  const user = await getUser();           // Wait 50ms
  const orders = await getOrders(user.id); // Wait 80ms (sequential!)
  const stats = await getStats(user.id);   // Wait 120ms (sequential!)
  // Total: 250ms — bad!
  
  return <div>...</div>;
}

// ✅ CORRECT: Parallel fetches
export default async function Dashboard() {
  const [user, orders, stats] = await Promise.all([
    getUser(),    // These all start at once
    getOrders(),  // Total: ~120ms (slowest one)
    getStats(),
  ]);
  
  return <div>...</div>;
}

Server Actions: Mutations Without APIs

Server Actions let you call server-side functions directly from Client Components without building a REST or GraphQL API:

// app/actions/cart.ts
'use server';

import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { revalidatePath } from 'next/cache';

export async function addToCart(productId: string) {
  const session = await auth();
  if (!session) throw new Error('Not authenticated');
  
  await db.cartItem.upsert({
    where: {
      userId_productId: {
        userId: session.user.id,
        productId,
      }
    },
    update: { quantity: { increment: 1 } },
    create: { userId: session.user.id, productId, quantity: 1 },
  });
  
  revalidatePath('/cart');
}
// app/products/[id]/page.tsx
import { addToCart } from '@/actions/cart';

export default function ProductPage({ params }) {
  return (
    <form action={addToCart.bind(null, params.id)}>
      <button type="submit">Add to Cart</button>
    </form>
  );
}

This works even with JavaScript disabled — it degrades gracefully to a standard form POST.

Production Performance Checklist

Things to verify before going to production:

// 1. Image optimization — always use next/image
import Image from 'next/image';

// ❌ Don't
<img src="/hero.jpg" width={1200} height={600} />

// ✅ Do
<Image 
  src="/hero.jpg" 
  width={1200} 
  height={600}
  priority  // for above-the-fold images
  alt="Hero image"
/>
// 2. Font optimization — use next/font
import { Inter } from 'next/font/google';

const inter = Inter({ 
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});
// 3. Bundle analysis — check your client bundle
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // your config
});

// Run: ANALYZE=true npm run build
// 4. Parallel route prefetching
// Prefetching happens automatically for <Link> in viewport
// For programmatic navigation:
import { useRouter } from 'next/navigation';

const router = useRouter();
router.prefetch('/dashboard'); // preload on hover/focus

The Edge Runtime: When to Use It

// Route that runs at Vercel Edge (low latency, limited APIs)
export const runtime = 'edge';

export async function GET(request: Request) {
  // Cannot use: Node.js APIs, most npm packages
  // Can use: fetch, Response, Request, Crypto, TextEncoder
  
  const geo = request.geo; // Vercel-specific: user's location
  const country = geo?.country ?? 'US';
  
  return Response.json({ country });
}

Use edge for: A/B testing, geo-routing, authentication middleware, feature flags. Avoid for: database queries, file I/O, heavy computation.

Common Pitfalls

// ❌ Pitfall 1: Importing server-only code in client components
// This will error at runtime, not build time (without 'server-only' package)
import { db } from '@/lib/db'; // Only works in Server Components

// Fix: Add 'server-only' to prevent accidental imports
// lib/db.ts:
import 'server-only';
export const db = ...;
// ❌ Pitfall 2: Using cookies/headers in static components
import { cookies } from 'next/headers';

// This makes the route dynamic (can't be statically generated)
const token = cookies().get('token');

// Fix: Move to a Server Action or Route Handler if static is desired
// ❌ Pitfall 3: Not handling loading states in Server Actions
async function handleSubmit(formData: FormData) {
  'use server';
  // This can take time — the client needs visual feedback
}

// Fix: Use useFormStatus in the Client Component
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

Conclusion

Next.js 15’s App Router rewards teams who take time to understand the rendering model. The initial cognitive load — Server vs Client, caching layers, streaming — pays dividends in applications that are genuinely fast and pleasant to build.

The key mental shift: data lives with the component that needs it. No more prop drilling data down from getServerSideProps, no more complex server state management. Each component fetches its own data; the framework handles deduplication, caching, and streaming.

Once that clicks, the rest follows naturally.

Web development Photo by Christopher Gower on Unsplash


Related: LLM Fine-Tuning: A Practical Guide

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)