<PS/>
Article

What I Actually Had to Change When Moving to React Server Components

React Server Components aren't just a build optimization — they change how you think about where code runs and why. Here's what the migration looked like on a real project and where the component boundary decisions were non-obvious.
PSParvej Shah
December 10, 2025· Last updated: August 26, 20264 min read
Next.js 16 and Turbopack Deep Dive Cover

When Next.js introduced the App Router with React Server Components, the mental model shift was more significant than the API change. The API changes are well-documented. The mental model shift is harder to articulate and easier to get wrong.

This is a practical account of what changed when building this portfolio site on Next.js 16 with Turbopack — which pages ended up as Server Components, which needed to be Client Components, and what the non-obvious boundary decisions looked like.

The Instinctive Wrong Move

The instinct when you hit a component that seems "complex" is to add "use client" to the top of the file. This works — the component now runs in the browser like it always did — but it often carries a hidden cost.

When you mark a parent component as a Client Component, every component it imports transitively becomes part of the client bundle too. If you've put "use client" on a layout component that imports your navigation, your blog post renderer, and your analytics component, you've just made all of those things client-side JavaScript even if none of them need interactivity.

The correct question isn't "does this component need to be a Client Component?" It's "what is the smallest leaf component that actually needs to run in the browser?"

The Actual Split on This Portfolio

Going through each section of the portfolio site with this question produced a clear pattern:

Everything in the blog system stayed as a Server Component. Blog posts fetch their content from PostgreSQL through Prisma. Markdown gets parsed and rendered. Cover images are served from the public directory. None of this involves user interaction. None of it changes based on client-side state. All of it is better handled on the server.

// This is a Server Component — no "use client"
// It runs at build time for static pages, at request time for dynamic ones

async function BlogPost({ slug }: { slug: string }) {
  const post = await db.post.findUnique({
    where: { slug, status: "PUBLISHED" },
    include: { coverImage: true },
  });

  if (!post) notFound();

  return (
    <article>
      <PostHeader post={post} />
      <MarkdownContent content={post.content} />
    </article>
  );
}

The navigation required a hybrid approach. The navbar is mostly static HTML, but it needs to highlight the active route — which requires knowing the current pathname, a client-side concern. The solution is to keep the navbar structure as a Server Component and extract only the active-state logic into a small Client Component.

// nav-link.tsx — Client Component (needs usePathname)
"use client";

import { usePathname } from "next/navigation";

export function NavLink({ href, children }: NavLinkProps) {
  const pathname = usePathname();
  const isActive = pathname.startsWith(href);

  return (
    <a href={href} className={isActive ? "text-emerald-400" : "text-slate-400"}>
      {children}
    </a>
  );
}

Forms are Client Components. The contact form and the admin dashboard forms require useState, onChange handlers, and submission logic. These are genuinely client-side concerns. Making them Client Components is correct.

Static Generation with Database Content

The blog posts and project pages are statically generated at build time. Next.js calls generateStaticParams to enumerate all the slugs, then pre-renders each page to static HTML.

export async function generateStaticParams() {
  const posts = await db.post.findMany({
    where: { status: "PUBLISHED" },
    select: { slug: true },
  });

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

When a new blog post is published through the admin interface, it triggers a Vercel deployment. The new build runs generateStaticParams, discovers the new slug, generates its static HTML, and deploys. The post goes live without any runtime database queries for future visitors.

One Actual Gotcha

There's a subtle issue with unstable_cache in Next.js when combined with static generation. The cache is keyed by the arguments you pass to it, but if you change the data in the database without triggering a new deployment, the cache will serve stale data indefinitely.

For content that changes through the admin interface, the admin routes that write to the database call revalidatePath or revalidateTag after each write, which purges the relevant cache entries. Without this, editing a blog post would update the database but leave the static HTML unchanged until the next deployment.

import { revalidatePath } from "next/cache";

async function updateBlogPost(id: string, data: PostUpdateData) {
  await db.post.update({ where: { id }, data });

  // Purge the static cache for this post's page
  revalidatePath(`/blog/${data.slug}`);

  // Purge the blog index page too
  revalidatePath("/blog");
}

This is the part that takes the most deliberate thought — not the Server/Client boundary, but the cache invalidation strategy. Get it right and content updates feel instant. Get it wrong and editors wonder why their changes aren't appearing.

Enjoyed the read?

Have a product idea worth building — let's talk.

Start a project