Skip to content
kobiakov.dev
Back to blog

Next.js 15 Production Architecture: App Router, PPR, and Caching at Scale

A practical guide to structuring a Next.js 15 application for production — Server Components, Partial Prerendering, cache invalidation, and deployment patterns.

Vladyslav Kobiakov2 min read
Next.js 15 Production Architecture: App Router, PPR, and Caching at Scale

Next.js 15 ships with a set of features that, used correctly, let you build applications that are simultaneously fast to load, cheap to serve, and easy to maintain. Used incorrectly, the same features create caching bugs that are painful to debug in production. This post covers the architecture decisions that matter.

The Mental Model: Where Does Code Run?

Before writing a single line, answer this for every component:

| Question | Answer → | |---|---| | Does this data change per-request? | Server Component with cache: 'no-store' | | Does this data change on a schedule? | Server Component with revalidate | | Does this data never change? | Server Component, fully static | | Does this need interactivity? | Client Component ('use client') |

The App Router default is Server Components. Adding 'use client' is an explicit opt-in, not the starting point.

Folder Structure for a Real Application

app/
  [locale]/
    (public)/          ← layout with navbar/footer
      page.tsx          ← home, static
      blog/
        page.tsx        ← listing, revalidate: 60
        [slug]/page.tsx ← detail, static + ISR
      portfolio/
        page.tsx        ← static
    (protected)/       ← layout with auth check
      dashboard/
        page.tsx        ← dynamic, no cache
  api/
    contact/route.ts   ← POST, rate-limited
    og/route.ts        ← dynamic OG image

Route groups (public) and (protected) share different layouts without affecting the URL. This is cleaner than wrapping every protected page in an auth check.

Server Components: Data Fetching Patterns

Parallel fetching with Promise.all

Never await sequentially when requests are independent:

// ❌ 600ms (two serial requests) const projects = await getProjects(); const testimonials = await getTestimonials(); // ✅ 300ms (parallel) const [projects, testimonials] = await Promise.all([ getProjects(), getTestimonials(), ]);
ts

Request deduplication with React cache

When multiple components on the same page need the same data, React.cache ensures the fetch runs once per render:

import { cache } from 'react'; export const getUser = cache(async (id: string) => { return prisma.user.findUnique({ where: { id } }); });
ts

Both <Header> and <Dashboard> can call getUser(id) — the database query executes once.

Partial Prerendering (PPR)

PPR is the most significant architecture change in Next.js 15. It lets a single route be partially static:

// next.config.ts export default { experimental: { ppr: 'incremental', }, }; // app/[locale]/page.tsx export const experimental_ppr = true; export default function HomePage() { return ( <> <HeroSection /> {/* static shell — served from CDN instantly */} <Suspense fallback={<ProjectsSkeleton />}> <FeaturedProjects /> {/* dynamic — streamed after shell */} </Suspense> </> ); }
ts

The HTML shell containing <HeroSection> is cached at the edge. The dynamic parts stream in behind a Suspense boundary. The user sees content in under 100ms; personalized data arrives shortly after.

Cache Invalidation Strategy

Next.js 15 has three cache layers. Know which one to invalidate:

import { revalidatePath, revalidateTag } from 'next/cache'; // Invalidate a specific URL (e.g., after publishing a blog post) await revalidatePath('/blog/[slug]', 'page'); // Invalidate all pages tagged with 'projects' (preferred for data-driven invalidation) await revalidateTag('projects'); // Tag a fetch so it can be invalidated by tag const data = await fetch('/api/projects', { next: { tags: ['projects'], revalidate: 300 }, });
ts

The revalidateTag approach scales better than revalidatePath because you tag data at the fetch level, not the route level. One API call invalidates every page that used that data.

Middleware for i18n and Auth

Middleware runs at the edge before any page renders. Keep it minimal — it runs on every request:

// middleware.ts import { NextRequest, NextResponse } from 'next/server'; import createIntlMiddleware from 'next-intl/middleware'; import { routing } from '@/i18n/routing'; const intlMiddleware = createIntlMiddleware(routing); export function middleware(request: NextRequest) { // Auth check for protected routes const isProtected = request.nextUrl.pathname.startsWith('/dashboard'); if (isProtected) { const session = request.cookies.get('next-auth.session-token'); if (!session) { return NextResponse.redirect(new URL('/login', request.url)); } } return intlMiddleware(request); }
ts

Avoid database calls in middleware. Check cookies or JWT claims only — anything heavier belongs in the page's Server Component.

Production Deployment Checklist

Before pushing to Vercel:

# 1. Type-check npx tsc --noEmit # 2. Lint npm run lint # 3. Build locally to catch static generation errors npm run build # 4. Check bundle size ANALYZE=true npm run build
bash

Watch for:

  • Server Components accidentally importing client-only packages (window, localStorage)
  • cookies() or headers() called outside of a dynamic context — this opts the entire route out of static generation
  • Images without explicit width/height causing layout shift

Conclusion

The App Router's power comes from understanding the static/dynamic boundary and placing it intentionally. Default to static, add Suspense boundaries for dynamic sections, tag your fetches for granular invalidation, and keep middleware thin. These four decisions cover 90% of production performance issues before they happen.

Share: