By 2026, Next.js’s App Router and React Server Components have stopped being “experimental” and become the default for most React work. The Pages Router is in maintenance. The pattern landscape has settled. This post is the working guide.

If you’ve been waiting for the dust to settle before adopting RSC, the dust has settled.

The mental model in one paragraph

In the App Router, layouts and pages are Server Components by default. They run on the server, fetch data directly, render to HTML, and send minimal JavaScript to the client. You opt into client behavior with "use client" at the top of a file. The result: less JavaScript shipped, faster page loads, but a few new rules to learn.

That’s it. The rest is mechanics.

File structure

app/
├── layout.tsx              # root layout (server)
├── page.tsx                # / (server)
├── loading.tsx             # streaming fallback
├── error.tsx               # error boundary (must be client)
├── not-found.tsx
├── posts/
   ├── layout.tsx
   ├── page.tsx            # /posts (server, renders list)
   └── [slug]/
       ├── page.tsx        # /posts/foo (server)
       └── components/
           └── like-button.tsx  # client component
└── api/
    └── webhook/
        └── route.ts        # API route handler

Conventions over config:

  • page.tsx makes a route.
  • layout.tsx wraps children; persists across navigation.
  • loading.tsx is the streamed fallback shown while server work happens.
  • error.tsx is a client error boundary.
  • route.ts exports GET, POST, etc. for traditional API endpoints.

Server vs client — the line that matters

// app/posts/page.tsx — Server Component (default)
import { db } from "@/lib/db";

export default async function Posts() {
  const posts = await db.posts.findMany();   // direct DB access — runs on server
  return (
    <ul>
      {posts.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}
// app/posts/[slug]/components/like-button.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} likes</button>;
}

Rules I learned the hard way:

  • Server Components can render Client Components as children. Easy.
  • Client Components cannot import Server Components. They can render Server Components passed as props or children. This is the famous “composition pattern.”
  • Hooks (useState, useEffect, useRouter) only work in Client Components.
  • Async/await works in Server Components, not in Client Components (that’s use() + Suspense).

Async params (Next 15 breaking change)

// app/posts/[slug]/page.tsx
type Props = { params: Promise<{ slug: string }>; searchParams: Promise<{ q?: string }> };

export default async function Post({ params, searchParams }: Props) {
  const { slug } = await params;
  const { q } = await searchParams;
  return <article>{/* ... */}</article>;
}

Yes — params and searchParams are now Promises in Next 15+. You must await them. This unlocks better streaming and parallel data fetching at the cost of some boilerplate. The codemod migrates older code automatically.

Data fetching

The killer feature: fetch is cached and dedupes by default.

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 },        // ISR — cache for 60s
  });
  return res.json();
}

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);  // multiple components calling getPost(slug)
                                     // share one fetch within this render
  return <article>{post.title}</article>;
}

next: { revalidate: N } is Incremental Static Regeneration — generate at request time, cache for N seconds, serve cached afterwards. Perfect for content that updates rarely.

For dynamic data:

const res = await fetch(url, { cache: "no-store" });   // every request fresh

Server Actions for mutations

Forget POST endpoints for forms.

// app/posts/[slug]/page.tsx
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

async function addComment(formData: FormData) {
  "use server";                      // marks this as a Server Action
  const text = formData.get("text") as string;
  await db.comments.insert({ postSlug: /* ... */, text });
  revalidatePath(`/posts/${slug}`);  // bust the page cache
}

export default async function Post({ params }) {
  // ...
  return (
    <form action={addComment}>
      <textarea name="text" required />
      <button type="submit">Comment</button>
    </form>
  );
}

What just happened:

  • "use server" exposes the function as a callable from the client.
  • Form submit calls the server function with FormData.
  • After the action, revalidatePath invalidates the cache so the next render shows the new comment.

This is the pattern for 90% of mutations: form submits, button clicks that change server state, optimistic updates. No API route, no client fetch, no JSON marshalling — types preserved end to end.

For optimistic updates use useOptimistic:

"use client";
import { useOptimistic } from "react";
import { addComment } from "../actions";

export function CommentForm({ initial, action }: Props) {
  const [optimistic, addOptimistic] = useOptimistic(
    initial,
    (state, newC: string) => [...state, { id: -1, text: newC }]
  );

  return (
    <form action={async (formData) => {
      const text = formData.get("text") as string;
      addOptimistic(text);             // show immediately
      await action(formData);          // server confirms
    }}>
      ...
    </form>
  );
}

Partial Prerendering (PPR)

Next 15’s quietly-huge feature. PPR mixes static and dynamic in one route:

  • The static shell is rendered at build time and cached at the edge.
  • The dynamic holes stream in on request.
import { Suspense } from "react";

export default async function Page() {
  return (
    <>
      <StaticHeader />               {/* prerendered */}
      <StaticHero />                 {/* prerendered */}

      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations />          {/* dynamic, streams in */}
      </Suspense>

      <StaticFooter />               {/* prerendered */}
    </>
  );
}

Result: the page first paint is instant (CDN-served HTML); personalized parts hydrate in.

Enable in next.config.ts:

export default { experimental: { ppr: "incremental" } };

Streaming with Suspense

export default async function Dashboard() {
  return (
    <div>
      <Header />                       {/* synchronous */}
      <Suspense fallback={<RevSkeleton />}>
        <RevenuePanel />               {/* slow query, streams */}
      </Suspense>
      <Suspense fallback={<UsersSkeleton />}>
        <ActiveUsersPanel />           {/* slow query, streams in parallel */}
      </Suspense>
    </div>
  );
}

Two slow queries run in parallel. The user sees the header instantly, then each panel streams in independently. The browser doesn’t wait for the slowest. This is the killer UX win of RSC.

Authentication and session

You need user info in many components. Don’t pass it through props for ten layers — use a server-side cache:

// lib/auth.ts
import { cookies } from "next/headers";
import { cache } from "react";
import { db } from "./db";

export const getCurrentUser = cache(async () => {
  const sid = (await cookies()).get("sid")?.value;
  if (!sid) return null;
  return db.users.findFirst({ where: eq(sessions.id, sid) });
});

React.cache dedupes within a single render. Call getCurrentUser() from any Server Component — only one DB hit per request.

For the auth flow itself, NextAuth.js v5 (Auth.js) is the maintained, integrated option. Clerk if you want managed.

Forms with validation

Pair Server Actions with Zod for type-safe validation:

// app/(forms)/contact/actions.ts
"use server";
import { z } from "zod";

const Schema = z.object({
  email: z.string().email(),
  message: z.string().min(10).max(1000),
});

export async function submit(prev: any, formData: FormData) {
  const parsed = Schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };

  await db.contact.insert(parsed.data);
  return { success: true };
}

Use with useActionState (formerly useFormState):

"use client";
import { useActionState } from "react";
import { submit } from "./actions";

export function ContactForm() {
  const [state, formAction] = useActionState(submit, null);
  return (
    <form action={formAction}>
      <input name="email" />
      {state?.errors?.email && <p>{state.errors.email}</p>}
      <textarea name="message" />
      <button>Send</button>
    </form>
  );
}

End-to-end validated. The schema becomes the type. The action handles success and error paths uniformly.

Performance: the Turbopack story

Next 15 stable Turbopack delivers ~10× faster local dev builds. next dev --turbo is the recommended default. Cold builds drop from minutes to seconds; HMR is sub-100ms in most projects.

For prod builds, Turbopack is still rolling out — check the latest release notes for status.

Caching, four layers

Next.js’s caching has four layers, all configurable, that confuse many developers:

  1. Request memoizationfetch calls dedupe within one render (React.cache).
  2. Data cachefetch(..., { next: { revalidate: N } }).
  3. Full route cache — static routes are cached and reused.
  4. Router cache — client-side cache of visited routes (in-memory, per session).

Mental model: by default, everything is cached. Opt out with cache: "no-store" or dynamic = "force-dynamic". This is the opposite of v12-and-before, and the source of most “why is my data stale?” surprises.

Gotchas worth knowing

1. Don’t import next/headers from a Client Component

headers(), cookies(), redirect() are server-only. They’re erased on the client. You’ll get a build error if you try.

2. Server Actions are POST endpoints

Network tab: every Action is a POST to /. They’re auth-gated by your middleware. Don’t expose them as the only auth path.

3. Mutations need revalidatePath / revalidateTag

Server Actions that change data should explicitly invalidate the cache. Otherwise the user sees stale content.

You can read cookies in any Server Component. To write a cookie, you need a Server Action or Route Handler. The Component-render path is supposed to be side-effect-free.

5. Dynamic imports work, but check bundle

next/dynamic loads a component at runtime. Useful for heavy client widgets. Watch the network panel — it’s easy to bundle the same dependency in 5 chunks if you’re not careful.

My production checklist

For a fresh Next 15 app heading to prod:

  • Server Components by default; explicit "use client" only on interactivity.
  • revalidate set on every fetch. Decide explicitly.
  • Server Actions for all forms.
  • Suspense boundaries around slow data, with skeletons.
  • loading.tsx at every dynamic route.
  • error.tsx at the same level.
  • PPR enabled (incremental).
  • Turbopack for dev.
  • Sentry/OTel for error tracking.
  • Auth.js or Clerk for auth.
  • Drizzle/Prisma for data, called directly from Server Components — no API layer between unless you need one.

When I’d pick something else

  • Astro — content-heavy, marketing sites, blogs (you’re reading one). Less JS shipped, simpler model.
  • TanStack Start — when you want a smaller framework with fine-grained control and don’t need RSC.
  • Remix — when you prefer loaders/actions semantics; merging into React Router but still distinct.
  • SvelteKit / Qwik — when smaller bundles and Core Web Vitals trump everything.

For most general-purpose React work, though, Next.js 15 is the default. The team’s bet on Server Components has paid off; the ergonomics are good; the performance ceiling is high.

Read this next

If you want a starter Next 15 app with auth, Drizzle, Server Actions, Sentry, and Turbopack wired up, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .