Skip to content
← Journal
12 min readAta Mohammadi

The Senior Frontend Engineering Blueprint, Part 2: Server Components, Server Actions and Optimistic UI

Part 2 of an eight-part build: where the Server/Client boundary actually goes, static pages that still regenerate themselves, the static-shell-plus-streaming-hole trade-off, and a webhook handler that verifies its own signature correctly.

Part 1 set up the toolchain: Docker Postgres, strict TypeScript, a Prisma schema, and one line in next.config.tscacheComponents: true — that this part leans on directly. Everything below assumes that flag is already on.

The question this part answers is a boring-sounding one that turns out to be the whole job: for any given piece of UI, which machine renders it, and when? Get it wrong and you either ship a page that blocks on a database query nobody needed synchronously, or a client bundle that re-fetches data the server already had in hand.

Server Components render once, Client Components render forever

A Server Component is not "a component that happens to run on the server" — it never reaches the browser at all. It executes once, produces output, and the result — not JavaScript, not HTML, a compact wire format React itself streams down — is what the client receives. A Client Component, by contrast, runs on the server once to produce the initial HTML, then ships as JavaScript and mounts for real in the browser, with all the state and event handling that implies.

The two compose by nesting, and the direction only works one way: a Server Component can render a Client Component, but a Client Component cannot import and render a Server Component directly — it can only receive one as a prop, already rendered, passed down from a Server Component ancestor.

"use client";

import { useState, useTransition } from "react";

interface ClientCounterProps {
  initialCount: number;
}

export function ClientCounter({ initialCount }: ClientCounterProps) {
  const [count, setCount] = useState(initialCount);
  const [isPending, startTransition] = useTransition();

  return (
    <div className="flex items-center gap-4 rounded-md border border-neutral-700 p-4">
      <span className="font-mono text-xl">{count}</span>
      <button
        onClick={() => {
          startTransition(() => {
            setCount((prev) => prev + 1);
          });
        }}
        disabled={isPending}
        className="rounded bg-blue-600 px-3 py-1 font-semibold text-white transition hover:bg-blue-500 disabled:opacity-50"
      >
        Increment
      </button>
    </div>
  );
}
import Link from "next/link";
import { ClientCounter } from "./ClientCounter";

interface ServerPostCardProps {
  post: {
    id: string;
    title: string;
    slug: string;
    content: string;
    viewCount: number;
    author: { name: string | null };
  };
}

// Directly async — no client runtime overhead, no useEffect fetch
export async function ServerPostCard({ post }: ServerPostCardProps) {
  return (
    <article className="space-y-3 rounded-lg border border-neutral-800 bg-neutral-900 p-6">
      <h2 className="text-2xl font-bold tracking-tight text-white">
        <Link href={`/posts/${post.slug}`} className="hover:underline">
          {post.title}
        </Link>
      </h2>
      <p className="text-sm text-neutral-400">By {post.author.name ?? "Anonymous"}</p>
      <p className="text-neutral-300">{post.content.slice(0, 150)}...</p>

      {/* The client boundary: everything below this line ships as JS */}
      <div className="pt-2">
        <ClientCounter initialCount={post.viewCount} />
      </div>
    </article>
  );
}

ServerPostCard never ships to the browser. ClientCounter does. The "use client" directive doesn't mark a component as "runs on the client" so much as it marks the boundary — everything imported from that file, and everything it renders, crosses into the browser bundle.

Static pages that still know about new rows

generateStaticParams pre-renders a dynamic route at build time for every value you give it — the resulting HTML is served from the CDN with no server round-trip. The catch in Next.js 16 is one every migration guide flags and every first attempt misses: dynamic route params are now a Promise, not a plain object, because the framework needs the freedom to resolve them lazily under streaming.

import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";

interface PostPageProps {
  params: Promise<{ slug: string }>;
}

// SSG: generates routes at build time
export async function generateStaticParams() {
  const posts = await prisma.post.findMany({
    select: { slug: true },
    take: 100, // pre-render the top 100 at build time; the rest render on first visit
  });

  return posts.map((post) => ({ slug: post.slug }));
}

export default async function PostDetailPage({ params }: PostPageProps) {
  const { slug } = await params; // MUST await — params is a Promise now

  const post = await prisma.post.findUnique({
    where: { slug },
    include: { author: true, categories: true },
  });

  if (!post) {
    notFound();
  }

  return (
    <main className="mx-auto max-w-3xl space-y-6 py-12">
      <h1 className="text-4xl font-extrabold text-white">{post.title}</h1>
      <div className="flex gap-2">
        {post.categories.map((c) => (
          <span key={c.id} className="rounded bg-neutral-800 px-2 py-1 text-xs text-neutral-300">
            {c.name}
          </span>
        ))}
      </div>
      <div className="prose prose-invert max-w-none text-neutral-200">{post.content}</div>
    </main>
  );
}
// The same singleton from Part 1 — reused everywhere in this series.
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

A route not covered by generateStaticParams — post 101 published after the build — still resolves: Next.js renders it on first request and caches the result, rather than 404ing.

The static shell, and the hole that streams into it

This is the part that used to need its own opt-in flag, and no longer does. With cacheComponents: true set in Part 1, any route that reads request-time data (cookies(), headers(), an uncached database call) inside a <Suspense> boundary automatically gets a static shell served instantly, with the dynamic pieces streamed in over the same connection as they resolve — no export const experimental_ppr = true needed. That per-route flag existed in Next.js 15; Next.js 16 removed it outright, because the behaviour it used to gate is now just what cacheComponents does everywhere.

import { Suspense } from "react";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";

// Dynamic hole 1: depends on the request's cookies
async function UserGreeting() {
  const cookieStore = await cookies(); // forces this component to run at request time
  const sessionToken = cookieStore.get("session_token")?.value;

  await new Promise((resolve) => setTimeout(resolve, 800)); // simulated latency

  return (
    <div className="rounded border border-blue-900 bg-blue-950/40 p-4 text-blue-200">
      Session: {sessionToken ? "Authenticated" : "Guest mode"}
    </div>
  );
}

// Dynamic hole 2: an uncached query
async function RealtimeAnalytics() {
  await new Promise((resolve) => setTimeout(resolve, 1500));
  const postCount = await prisma.post.count();

  return (
    <div className="rounded border border-emerald-900 bg-emerald-950/40 p-4 text-emerald-200">
      Total platform posts: {postCount}
    </div>
  );
}

export default function DashboardPage() {
  return (
    <section className="space-y-6 p-8">
      {/* Static shell: served instantly, cacheable at the edge */}
      <header className="border-b border-neutral-800 pb-4">
        <h1 className="text-3xl font-bold text-white">Platform dashboard</h1>
        <p className="text-neutral-400">This shell is static. Everything below streams in.</p>
      </header>

      <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
        <Suspense fallback={<div className="h-20 animate-pulse rounded bg-neutral-800" />}>
          <UserGreeting />
        </Suspense>
        <Suspense fallback={<div className="h-20 animate-pulse rounded bg-neutral-800" />}>
          <RealtimeAnalytics />
        </Suspense>
      </div>
    </section>
  );
}

Without a Suspense boundary, calling cookies() anywhere in the tree turns the entire page dynamic — every millisecond of the two dynamic holes above would sit in front of Time to First Byte instead of streaming in behind an already-served shell. This is only the shape of the mechanism; for the wire format Next.js actually streams and how cacheComponents unified ppr, useCache and dynamicIO into one flag, Next.js in 2026: The Flight Wire Format, Cache Components and Tag Invalidation goes considerably deeper — including the invalidation race a single-argument revalidateTag used to hide, which is exactly the gotcha in the next section.

Caching a function, not a route

use cache marks a function, component, or file as cacheable directly — no more monkey-patching fetch. cacheLife picks a TTL profile and cacheTag gives the entry a name you can invalidate by. Both used to need an unstable_ prefix; Next.js 16 stabilised them, so the aliased import from older tutorials is dead code waiting to confuse someone:

import "server-only";
import { cacheLife, cacheTag } from "next/cache"; // no unstable_ prefix as of Next.js 16
import { prisma } from "@/lib/prisma";

export async function getCachedTrendingPosts() {
  "use cache";
  cacheLife("hours");
  cacheTag("trending-posts");

  return await prisma.post.findMany({
    where: { published: true },
    orderBy: { viewCount: "desc" },
    take: 5,
    select: { id: true, title: true, slug: true, viewCount: true },
  });
}

And the deprecation that actually bites: revalidateTag used to accept one argument and expire the entry immediately, forcing the next request into a blocking cache miss. Next.js 16 requires a second argument — the recommended value is "max", which marks the entry stale and serves it once more, refreshing in the background, instead of stalling the next visitor.

"use server";

import { revalidateTag, revalidatePath } from "next/cache";

export async function purgeTrendingPostsCache() {
  revalidateTag("trending-posts", "max"); // single-argument form is deprecated in Next.js 16
}

export async function purgePostPath(slug: string) {
  revalidatePath(`/posts/${slug}`);
}

Mutations without a fetch client

Server Actions are functions that happen to be callable from the browser — no route handler, no client-side fetch, no hand-rolled loading state, because React 19's useActionState and useOptimistic give you all three.

import { z } from "zod";

export const CreatePostSchema = z.object({
  title: z.string().min(5, "Title must have at least 5 characters").max(100),
  content: z.string().min(20, "Content must have at least 20 characters"),
  authorEmail: z.string().email("Invalid author email"),
  category: z.string().min(2, "Category required"),
});

export type CreatePostInput = z.infer<typeof CreatePostSchema>;
"use server";

import { CreatePostSchema } from "./schemas";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export type ActionState = {
  success: boolean;
  message?: string;
  errors?: Record<string, string[]>;
};

export async function createPostAction(
  prevState: ActionState,
  formData: FormData,
): Promise<ActionState> {
  const rawData = {
    title: formData.get("title"),
    content: formData.get("content"),
    authorEmail: formData.get("authorEmail"),
    category: formData.get("category"),
  };

  const validated = CreatePostSchema.safeParse(rawData);
  if (!validated.success) {
    return {
      success: false,
      message: "Validation failed.",
      errors: validated.error.flatten().fieldErrors,
    };
  }

  const { title, content, authorEmail, category } = validated.data;
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)+/g, "");

  try {
    await prisma.post.create({
      data: {
        title,
        slug,
        content,
        published: true,
        author: {
          connectOrCreate: {
            where: { email: authorEmail },
            create: { email: authorEmail, name: authorEmail.split("@")[0] },
          },
        },
        categories: {
          connectOrCreate: { where: { name: category }, create: { name: category } },
        },
      },
    });

    revalidatePath("/posts");
    return { success: true, message: "Post published successfully!" };
  } catch (error) {
    return {
      success: false,
      message: error instanceof Error ? error.message : "Database error.",
    };
  }
}

useOptimistic inserts the new row into the list before the server has confirmed anything; useActionState threads the pending state and the last result through the form without a useState/useEffect pair to keep in sync by hand.

"use client";

import { useActionState, useOptimistic, useRef } from "react";
import { createPostAction, type ActionState } from "@/app/actions/posts";

interface PostItem {
  id: string;
  title: string;
}

export function CreatePostForm({ initialPosts }: { initialPosts: PostItem[] }) {
  const formRef = useRef<HTMLFormElement>(null);

  const [state, formAction, isPending] = useActionState<ActionState, FormData>(
    createPostAction,
    { success: false },
  );

  const [optimisticPosts, setOptimisticPosts] = useOptimistic(
    initialPosts,
    (currentPosts, newTitle: string) => [
      { id: `temp-${Date.now()}`, title: newTitle },
      ...currentPosts,
    ],
  );

  const handleSubmit = (formData: FormData) => {
    const title = formData.get("title") as string;
    if (title) {
      setOptimisticPosts(title); // updates the UI before the server responds
    }
    formAction(formData);
  };

  return (
    <div className="mx-auto max-w-xl space-y-6">
      <form ref={formRef} action={handleSubmit} className="space-y-4 rounded-lg bg-neutral-900 p-6">
        <div>
          <label className="block text-sm font-medium text-neutral-300">Title</label>
          <input name="title" required className="w-full rounded border border-neutral-700 bg-neutral-800 p-2 text-white" />
          {state.errors?.title && <p className="text-xs text-red-500">{state.errors.title[0]}</p>}
        </div>
        <div>
          <label className="block text-sm font-medium text-neutral-300">Author email</label>
          <input name="authorEmail" type="email" required className="w-full rounded border border-neutral-700 bg-neutral-800 p-2 text-white" />
          {state.errors?.authorEmail && <p className="text-xs text-red-500">{state.errors.authorEmail[0]}</p>}
        </div>
        <div>
          <label className="block text-sm font-medium text-neutral-300">Category</label>
          <input name="category" required className="w-full rounded border border-neutral-700 bg-neutral-800 p-2 text-white" />
        </div>
        <div>
          <label className="block text-sm font-medium text-neutral-300">Content</label>
          <textarea name="content" rows={4} required className="w-full rounded border border-neutral-700 bg-neutral-800 p-2 text-white" />
          {state.errors?.content && <p className="text-xs text-red-500">{state.errors.content[0]}</p>}
        </div>
        <button
          type="submit"
          disabled={isPending}
          className="w-full rounded bg-emerald-600 py-2 font-bold text-white transition hover:bg-emerald-500 disabled:opacity-50"
        >
          {isPending ? "Publishing..." : "Submit post"}
        </button>
        {state.message && (
          <p className={`text-sm ${state.success ? "text-green-400" : "text-red-400"}`}>{state.message}</p>
        )}
      </form>

      <section>
        <h3 className="text-lg font-semibold text-white">Live posts list</h3>
        <ul className="mt-2 divide-y divide-neutral-800 rounded border border-neutral-800">
          {optimisticPosts.map((p) => (
            <li key={p.id} className="p-3 text-neutral-300">{p.title}</li>
          ))}
        </ul>
      </section>
    </div>
  );
}

The one place raw bytes matter: webhooks

A Server Action or a typical Route Handler reads req.json() and moves on. A webhook cannot, because HMAC signature verification operates on the exact bytes the sender hashed — and JSON.parse followed by re-serialisation does not reliably reproduce them: key order and whitespace are not part of JSON's semantics, so the string you'd re-hash after parsing is not guaranteed to be the string the sender signed. Read the raw text first, verify against that, and only then parse it.

import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";

export const dynamic = "force-dynamic";

export async function POST(req: NextRequest) {
  try {
    const rawBody = await req.text(); // raw bytes, before anything touches them
    const signature = req.headers.get("x-webhook-signature");
    const secret = process.env.WEBHOOK_SIGNING_SECRET;

    if (!signature || !secret) {
      return NextResponse.json(
        { error: "Missing cryptographic signature or configuration" },
        { status: 400 },
      );
    }

    const expectedSignature = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");

    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature),
    );

    if (!isValid) {
      return NextResponse.json({ error: "Invalid HMAC signature" }, { status: 401 });
    }

    const event = JSON.parse(rawBody); // safe to parse now that it's verified

    switch (event.type) {
      case "payment.succeeded":
        break;
      default:
        console.warn(`Unhandled webhook event: ${event.type}`);
    }

    return NextResponse.json({ received: true }, { status: 200 });
  } catch (err) {
    return NextResponse.json(
      { error: err instanceof Error ? err.message : "Internal Server Error" },
      { status: 500 },
    );
  }
}

crypto.timingSafeEqual matters as much as the byte ordering: a naive === comparison on the two hex strings leaks timing information about how many leading characters matched, which is a real (if slow) side channel for forging a signature byte by byte.

A short note on useMemo and useCallback

The rule that still matters when you write these by hand: useMemo caches a value, useCallback caches a function's identity so a memoised child doesn't re-render just because its parent did. The React Compiler now inserts most of this automatically when it can prove your code follows the rules of React — how it proves that, and exactly where it bails out and leaves the manual hook in charge, is its own article: React Compiler Internals: How Auto-Memoisation Works, and Where It Gives Up.

The compressed version

  • Server Components never reach the browser; "use client" marks the boundary crossing, not "this runs on the client."
  • Dynamic route params are a Promise in Next.js 16 — await params or the build fails.
  • experimental_ppr is gone. With cacheComponents: true, a <Suspense> boundary around dynamic data is all that's needed for a static shell plus streamed holes.
  • unstable_cacheLife/unstable_cacheTag are just cacheLife/cacheTag now — drop the alias.
  • revalidateTag(tag) alone is deprecated; pass "max" as the second argument.
  • Verify a webhook's raw bytes before you parse them, and compare signatures with timingSafeEqual, not ===.

Part 3 picks up where this leaves off: parallel and intercepting routes, hydrating TanStack Query from the server, and a Tailwind v4 design system built on class-variance-authority.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.