The App Router Revolution

The Next.js App Router, built on React Server Components (RSC), fundamentally changes how we think about data fetching, rendering, and code organisation. Introduced in Next.js 13 and now the recommended default, it enables a new set of patterns that were previously impossible.

React Server Components (RSC)

Server Components run exclusively on the server and their JavaScript is never sent to the client. This means:

  • Direct database access without an API layer
  • Zero client-side JS for server components (no hydration cost)
  • Sensitive data stays on the server — keys, tokens, business logic
  • Automatic rendering optimisation
// app/posts/page.tsx — Server Component (the default)
import { db } from "@/lib/db";

export default async function PostsPage() {
  // Direct database query — no API route needed
  const posts = await db.posts.findMany({
    orderBy: { createdAt: "desc" },
    take: 10,
  });

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Client Components

Add the "use client" directive when you need interactivity, state, browser APIs, or libraries that rely on browser globals (like Framer Motion):

"use client";
import { useState } from "react";

export default function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked((v) => !v)}>
      {liked ? "❤️ Liked" : "🤍 Like"}
    </button>
  );
}

Key insight: Push "use client" as far down the component tree as possible. A Server Component can import and render a Client Component, but not the reverse.

Special Files

The App Router uses a file-system convention with reserved file names per route segment:

FilePurpose
page.tsxUnique UI for a route (makes the route publicly accessible)
layout.tsxShared UI that wraps children, persists across navigations
loading.tsxAutomatic Suspense boundary — shown while page loads
error.tsxError boundary with reset() function
not-found.tsxCustom 404 UI
route.tsAPI endpoint (replaces /pages/api)
template.tsxLike layout but re-mounts on every navigation

Nested Layouts

Layouts nest automatically and persist across child navigations, making them ideal for sidebars, headers, and navigation:

app/
├── layout.tsx          ← root layout (html, body)
├── page.tsx            ← /
├── blog/
│   ├── layout.tsx      ← blog layout (sidebar, etc.)
│   ├── page.tsx        ← /blog
│   └── [slug]/
│       └── page.tsx    ← /blog/my-post
└── dashboard/
    ├── layout.tsx      ← dashboard layout (different nav)
    └── page.tsx        ← /dashboard

Data Fetching Patterns

Parallel Fetching — Avoid Waterfalls

// ❌ Sequential — 600ms total (300 + 300)
const user = await fetchUser(); // 300ms
const posts = await fetchPosts(); // 300ms (waits for user)

// ✅ Parallel — 300ms total
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);

Incremental Static Regeneration (ISR)

// Revalidate cached data every hour
const data = await fetch("https://api.example.com/data", {
  next: { revalidate: 3600 },
});

// Tag-based revalidation
const data = await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] },
});

// Trigger from a Server Action or API route:
import { revalidateTag } from "next/cache";
revalidateTag("posts");

No Cache — Always Fresh

const data = await fetch("https://api.example.com/live", {
  cache: "no-store",
});

Streaming with Suspense

Streaming allows the server to send parts of a page as they become ready, dramatically improving perceived performance for data-heavy pages:

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* This renders immediately */}
      <StaticContent />

      {/* This streams in when ready — user sees skeleton first */}
      <Suspense fallback={<RevenueChartSkeleton />}>
        <RevenueChart /> {/* slow async component */}
      </Suspense>

      <Suspense fallback={<RecentOrdersSkeleton />}>
        <RecentOrders /> {/* another slow async component */}
      </Suspense>
    </div>
  );
}

Server Actions

Server Actions let you run server-side mutations directly from form submissions or event handlers, without creating API routes:

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  await db.posts.create({ data: { title } });
  revalidatePath("/posts");
}

// app/new-post/page.tsx
import { createPost } from "../actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" />
      <button type="submit">Create Post</button>
    </form>
  );
}

Best Practices

  • Default to Server Components — opt into "use client" only when needed
  • Use React.cache() to deduplicate duplicate requests within a single render
  • Prefer Promise.all over sequential await when multiple fetches are independent
  • Use Suspense boundaries to progressively load heavy components
  • Use Server Actions for mutations — simpler than API routes for most cases
  • Keep secrets and business logic in Server Components — they never reach the client bundle

Conclusion

The App Router represents the future of React applications — a model where the server and client collaborate intelligently, with each piece of code running where it makes most sense. The learning curve is real, but the performance and developer experience benefits make it well worth the investment.