Next.js 15 App Router: Mastering Server Components, Server Actions, and Partial Prerendering for Peak Performance

Development tutorial - IT technology blog
Development tutorial - IT technology blog

App Router isn’t just an upgrade — it’s an entirely different way to write React

When first opening an App Router project, many people think it’s just a new folder structure. Wrong. App Router changes how you think about data fetching, form handling, and even how the component tree works.

I refactored a 50K-line codebase from Pages Router to App Router — it took about 3 weeks. The most expensive lesson wasn’t “App Router is complicated” — it was that you need solid test coverage before touching anything. When the component tree structure changes completely, without tests you have no idea what you’re breaking.

Three concepts to understand before you start:

  • Server Components — run entirely on the server, sending no JavaScript to the client
  • Server Actions — replace API routes for mutations (form submits, data updates)
  • Partial Prerendering (PPR) — combines static and dynamic in the same page, no need to choose between them

Setting Up Next.js 15 with App Router

npx create-next-app@latest my-app --typescript --tailwind --eslint --app
cd my-app
npm run dev

App Router directory structure after project creation:

app/
├── layout.tsx          # Root layout (Server Component)
├── page.tsx            # Home page (Server Component)
├── loading.tsx         # Loading UI (automatic Suspense boundary)
├── error.tsx           # Error boundary (MUST be a Client Component)
├── globals.css
└── dashboard/
    ├── layout.tsx      # Nested layout
    └── page.tsx

The common pitfall for newcomers: by default, every component inside the app/ directory is a Server Component. Only when you add "use client" at the top of the file does it become a Client Component.

Detailed Configuration

Server Components — Fetch Data Directly, No useEffect Needed

Instead of the familiar useEffect + useState + loading state loop (or TanStack Query for complex server state), Server Components let you write directly:

// app/posts/page.tsx — Server Component (default)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // Cache for 1 hour, auto revalidate
  })
  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()
  
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

A bonus few people notice: Server Components send no JavaScript to the client. Use heavy libraries like date-fns or marked just for rendering — run it server-side and you’re done; the client bundle doesn’t grow by a single byte.

Cache strategy tips:

  • cache: 'force-cache' — rarely changing static data (default)
  • next: { revalidate: N } — ISR, auto-refresh after N seconds
  • cache: 'no-store' — dynamic data, always fresh on every request

But knowing when not to use Server Components matters even more. You need a Client Component when you have useState/useEffect, event handlers, browser APIs, or third-party libraries that use React context:

// app/components/LikeButton.tsx — Client Component
"use client"

import { useState } from 'react'

export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount)
  return (
    <button onClick={() => setCount(c => c + 1)}>
      ❤️ {count}
    </button>
  )
}

Server Actions — Form Submit Without an API Route

Server Actions — a pattern Remix pioneered — are my favorite part of switching to App Router. Before, every form needed its own API route — validation here, cache revalidation there, redirect somewhere else. Now it all lives in one place:

// app/actions/posts.ts
"use server"

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  
  if (!title?.trim()) {
    return { error: 'Title cannot be empty' }
  }
  
  // Call database directly — runs entirely on the server
  await db.post.create({ data: { title, content } })
  
  revalidatePath('/posts') // Clear cache for /posts page
  redirect('/posts')
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } })
  revalidatePath('/posts')
}

Combined with useActionState to display errors on the client side:

// app/posts/new/CreatePostForm.tsx
"use client"

import { useActionState } from 'react'
import { createPost } from '../actions/posts'

export function CreatePostForm() {
  const [state, action, isPending] = useActionState(createPost, null)
  
  return (
    <form action={action}>
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

Partial Prerendering — Eliminating the Static vs. Dynamic Trade-off

Before, you had to choose: make this page static (fast, but data isn’t real-time) or dynamic (fresh data, but higher TTFB). PPR eliminates that trade-off. The static shell of the page renders immediately — TTFB stays the same as a static page. The dynamic parts stream in afterward via Suspense.

Enable PPR in config:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

export default nextConfig

Using it in a page — the static parts render immediately, the dynamic parts stream in afterward:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { StaticHeader } from './StaticHeader'       // Renders immediately — no waiting
import { UserStats } from './UserStats'             // Streams in later
import { RecentActivity } from './RecentActivity'   // Streams in later

export default function DashboardPage() {
  return (
    <div>
      {/* Static shell — renders immediately, TTFB = static page */}
      <StaticHeader />
      
      {/* Dynamic — streams in after the static shell is displayed */}
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

The result: users see the header and layout instantly. Stats and activity stream in incrementally — no blank screen, no waiting for all data to load before anything renders.

Performance Testing & Monitoring

Reading npm run build Output

npm run build

Next.js lists each route with symbols:

  • — Static: renders at build time, fastest
  • — ISR: static but periodically revalidated
  • ƒ — Dynamic: renders on every request

The goal is to keep as many routes as or as possible. Any route that becomes ƒ unnecessarily should be reviewed — it’s usually caused by accidentally calling cookies() or headers() inside a component.

Debugging Cache and Route Behavior

# View detailed cache hit/miss logs in development
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev

# Check bundle size per route
npx @next/bundle-analyzer

Pre-Production Deploy Checklist

  • Static pages use generateStaticParams() to pre-render correctly?
  • No accidental cookies()/headers() calls in static components (they’ll silently switch to dynamic)
  • loading.tsx is present on routes with slow data fetching
  • Using Next.js <Image> instead of plain <img> — automatically optimizes and lazy loads
  • Server Actions have explicit error handling — return error objects instead of throwing
  • Suspense boundaries are placed correctly to avoid request waterfalls

After running App Router in production for several months, the biggest benefit I’ve noticed isn’t speed — it’s a much cleaner codebase. No more mixing getServerSideProps and getStaticProps. No need to create an API route just to handle a single form — and when you do need type-safe client-server contracts, tRPC integrates naturally with App Router. Data fetching lives exactly where it belongs. If you’re still on Pages Router and don’t know where to start — try building one small feature with App Router first. Next.js runs both side by side, so you don’t need to migrate everything at once.

Share: