IAMUVIN

Next.js & React

React Server Components: A Practical Guide for Real Projects

Uvin Vindula·May 13, 2024·12 min read
Share

TL;DR

React Server Components changed how I build every application. Since Next.js 13 introduced them, I have shipped five production projects using RSC as the default rendering strategy — including EuroParts Lanka (an automotive parts platform) and iamuvin.com (this site). The results are consistent: 40-60% reduction in client-side JavaScript, faster initial page loads, simpler data fetching, and a cleaner separation between what runs on the server and what runs in the browser. But RSC also introduced a new mental model that trips up experienced React developers. This guide covers everything I have learned from building real projects with Server Components — the decision tree I use for every component, the composition patterns that actually work, the mistakes I made so you do not have to, and real performance numbers from production deployments.


What Server Components Actually Are

Server Components are React components that run exclusively on the server. They never ship JavaScript to the browser. They never hydrate. They render once on the server, produce HTML, and that is it.

This sounds simple, but the implications are enormous.

In traditional React, every component you write — whether it is a static footer or a complex interactive form — gets bundled into JavaScript, shipped to the browser, parsed, and hydrated. A component that displays a product description requires the same client-side processing as a component that handles drag-and-drop sorting. That is wasteful.

Server Components fix this by splitting the component tree into two categories:

Server Components render on the server. They can directly access databases, read files, call internal APIs, and use server-only dependencies. They produce HTML that gets streamed to the browser. Zero JavaScript cost on the client.

Client Components render on both server (for initial HTML) and client (for interactivity). They handle state, effects, event handlers, and browser APIs. They are what React components were before RSC — they just need an explicit "use client" directive now.

The key insight that took me months to fully internalize: Server Components are the default in Next.js App Router. Every component is a Server Component unless you explicitly mark it with "use client". This is the opposite of how React worked before, and it is the right default.

Here is what a Server Component looks like in practice. This is from the parts catalog on EuroParts Lanka:

tsx
// app/parts/[category]/page.tsx
// No "use client" — this is a Server Component by default

import { db } from "@/lib/database";
import { PartCard } from "@/components/part-card";
import { CategoryHeader } from "@/components/category-header";

interface PageProps {
  params: Promise<{ category: string }>;
}

export default async function CategoryPage({ params }: PageProps) {
  const { category } = await params;

  const parts = await db.part.findMany({
    where: { category, inStock: true },
    include: { supplier: true, images: true },
    orderBy: { popularity: "desc" },
    take: 50,
  });

  return (
    <main>
      <CategoryHeader category={category} count={parts.length} />
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {parts.map((part) => (
          <PartCard key={part.id} part={part} />
        ))}
      </div>
    </main>
  );
}

Notice what is happening here: the component is async. It queries the database directly. There is no useEffect, no loading state management, no API route sitting in between. The database query runs on the server, the HTML renders, and the browser receives a fully populated page. The Prisma client, the database driver, the query logic — none of that ships to the client bundle.


Server vs Client — The Decision Tree

After building five production applications with RSC, I have developed a decision tree that I apply to every single component I create. It is simple and it has never steered me wrong.

Start with Server. Add `"use client"` only when you must.

Here is the full tree:

  1. Does this component use `useState`, `useReducer`, or any state hook? Yes → Client Component.
  2. Does this component use `useEffect`, `useLayoutEffect`, or any effect hook? Yes → Client Component.
  3. Does this component attach event handlers like `onClick`, `onChange`, `onSubmit`? Yes → Client Component.
  4. Does this component use browser-only APIs like `window`, `document`, `localStorage`? Yes → Client Component.
  5. Does this component use custom hooks that depend on any of the above? Yes → Client Component.
  6. Everything else? Server Component.

In practice, this means the vast majority of your application stays on the server. On iamuvin.com, roughly 80% of components are Server Components. On EuroParts Lanka, it is closer to 75% because the parts search and filtering interface requires more interactivity.

Here is what typically stays as Server Components:

  • Page layouts and structural wrappers
  • Navigation menus (the links themselves — not dropdown toggles)
  • Content display components (articles, product descriptions, profiles)
  • Data fetching wrappers
  • Metadata and SEO components
  • Footer and static sections

Here is what needs "use client":

  • Form inputs and validation
  • Search bars with live filtering
  • Modal dialogs and dropdowns
  • Theme toggles and preference controls
  • Animated components using Framer Motion or GSAP
  • Components using third-party libraries that access browser APIs

The most common mistake I see developers make is reaching for "use client" too early. Someone needs to show a list of products with a "sort by" dropdown, and they mark the entire page as a Client Component. Wrong. The product list is a Server Component. The sort dropdown is a tiny Client Component. Compose them together.


Data Fetching in Server Components

Data fetching is where Server Components truly shine. The old React model — useEffect on mount, set loading state, fetch from an API route, handle errors, update state — was a verbose ceremony that every developer performed hundreds of times. Server Components eliminate all of it.

Direct Database Access

In Server Components, you can query your database directly. No API route. No client-side fetch. No loading spinner for the initial render.

tsx
// app/dashboard/page.tsx

import { db } from "@/lib/database";
import { getCurrentUser } from "@/lib/auth";
import { StatsGrid } from "@/components/stats-grid";
import { RecentOrders } from "@/components/recent-orders";

export default async function DashboardPage() {
  const user = await getCurrentUser();

  const [stats, recentOrders] = await Promise.all([
    db.order.aggregate({
      where: { userId: user.id },
      _count: true,
      _sum: { total: true },
    }),
    db.order.findMany({
      where: { userId: user.id },
      orderBy: { createdAt: "desc" },
      take: 10,
      include: { items: { include: { part: true } } },
    }),
  ]);

  return (
    <div className="space-y-8">
      <StatsGrid
        totalOrders={stats._count}
        totalSpent={stats._sum.total ?? 0}
      />
      <RecentOrders orders={recentOrders} />
    </div>
  );
}

Two things to notice here. First, I am using Promise.all to run both queries in parallel. This is critical. Sequential await calls create data fetching waterfalls — exactly the problem Server Components are supposed to solve. If your queries are independent, run them in parallel. Always.

Second, the data never leaves the server. The user's order history, the aggregate stats, the full order details — none of this is serialized and sent to the browser as JSON. The server renders it into HTML and streams that. This is better for performance and better for security.

Fetching from External APIs

Server Components are not limited to database queries. You can call any external API, and the request runs on the server with no CORS issues and no exposed API keys.

tsx
// app/parts/[id]/page.tsx

import { getPartById } from "@/lib/parts";
import { PriceDisplay } from "@/components/price-display";

async function getExchangeRate(from: string, to: string): Promise<number> {
  const response = await fetch(
    `https://api.exchangerate.host/convert?from=${from}&to=${to}`,
    { next: { revalidate: 3600 } }
  );
  const data = await response.json();
  return data.result;
}

export default async function PartDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  const [part, lkrRate] = await Promise.all([
    getPartById(id),
    getExchangeRate("GBP", "LKR"),
  ]);

  return (
    <div>
      <h1 className="text-2xl font-bold">{part.name}</h1>
      <PriceDisplay
        gbpPrice={part.price}
        lkrPrice={part.price * lkrRate}
      />
    </div>
  );
}

The next: { revalidate: 3600 } option tells Next.js to cache the exchange rate for one hour. This is Incremental Static Regeneration at the fetch level — the first request hits the API, subsequent requests within the hour serve the cached result. On EuroParts Lanka, this pattern alone saved us from hitting external API rate limits.


Composition Patterns

Composition is the core skill of working with Server Components. The goal is to keep as much of your component tree on the server as possible while surgically inserting Client Components only where interactivity demands it.

Pattern 1: Server Parent, Client Child

This is the most common pattern. A Server Component fetches data and renders the page structure. A Client Component handles one specific interactive element within it.

tsx
// app/parts/page.tsx — Server Component
import { db } from "@/lib/database";
import { SearchFilter } from "@/components/search-filter";
import { PartGrid } from "@/components/part-grid";

export default async function PartsPage() {
  const categories = await db.category.findMany({
    orderBy: { name: "asc" },
  });

  const parts = await db.part.findMany({
    where: { inStock: true },
    take: 50,
  });

  return (
    <div>
      <h1 className="text-3xl font-bold">Parts Catalog</h1>
      {/* Client Component for interactive filtering */}
      <SearchFilter categories={categories} />
      {/* Server Component for static display */}
      <PartGrid parts={parts} />
    </div>
  );
}
tsx
// components/search-filter.tsx — Client Component
"use client";

import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";

interface Category {
  id: string;
  name: string;
}

export function SearchFilter({ categories }: { categories: Category[] }) {
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();
  const router = useRouter();
  const searchParams = useSearchParams();

  function handleSearch(value: string) {
    setQuery(value);
    startTransition(() => {
      const params = new URLSearchParams(searchParams.toString());
      if (value) {
        params.set("q", value);
      } else {
        params.delete("q");
      }
      router.push(`/parts?${params.toString()}`);
    });
  }

  return (
    <div className="flex gap-4">
      <input
        type="search"
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search parts..."
        className="rounded-lg border px-4 py-2"
      />
      {isPending && <span className="text-sm text-muted">Searching...</span>}
    </div>
  );
}

The categories list is fetched on the server and passed as a prop. The search interaction happens on the client. The page re-renders on the server when the URL changes, keeping the expensive database query server-side.

Pattern 2: Client Component with Server Component Children

This is the pattern that confuses people. You can pass Server Components as children to Client Components. The Server Component renders on the server, and the Client Component receives the rendered output.

tsx
// components/collapsible-section.tsx — Client Component
"use client";

import { useState } from "react";
import type { ReactNode } from "react";

interface CollapsibleSectionProps {
  title: string;
  children: ReactNode;
}

export function CollapsibleSection({
  title,
  children,
}: CollapsibleSectionProps) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="rounded-lg border">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="flex w-full items-center justify-between p-4"
      >
        <h3 className="font-semibold">{title}</h3>
        <span>{isOpen ? "−" : "+"}</span>
      </button>
      {isOpen && <div className="p-4 pt-0">{children}</div>}
    </div>
  );
}
tsx
// app/parts/[id]/page.tsx — Server Component
import { db } from "@/lib/database";
import { CollapsibleSection } from "@/components/collapsible-section";
import { SpecificationTable } from "@/components/specification-table";

export default async function PartPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const specs = await db.specification.findMany({ where: { partId: id } });

  return (
    <CollapsibleSection title="Technical Specifications">
      {/* This Server Component renders on the server,
          even though it's inside a Client Component */}
      <SpecificationTable specs={specs} />
    </CollapsibleSection>
  );
}

The SpecificationTable is a Server Component. It renders on the server and the result is passed as the children prop to CollapsibleSection. The toggle logic is on the client, but the specification data never touches the client bundle. This is the composition pattern I use most frequently.

Pattern 3: The Boundary Push-Down

When I first started with RSC, I would mark a parent component as "use client" because one child needed interactivity. This is wrong. Push the "use client" boundary as far down the tree as possible.

Wrong approach:

tsx
// DON'T DO THIS — entire page becomes a Client Component
"use client";

import { useState } from "react";

export default function ProductPage({ product }) {
  const [showReviews, setShowReviews] = useState(false);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <img src={product.image} alt={product.name} />
      {/* This one button forced the entire page to become a Client Component */}
      <button onClick={() => setShowReviews(!showReviews)}>
        {showReviews ? "Hide" : "Show"} Reviews
      </button>
      {showReviews && <ReviewList productId={product.id} />}
    </div>
  );
}

Correct approach:

tsx
// app/products/[id]/page.tsx — Server Component
import { db } from "@/lib/database";
import { ReviewToggle } from "@/components/review-toggle";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <img src={product.image} alt={product.name} />
      {/* Only this small component is a Client Component */}
      <ReviewToggle productId={product.id} />
    </div>
  );
}

The product name, description, and image render on the server with zero JavaScript cost. Only the review toggle — which needs useState — becomes a Client Component. This pattern reduced the client bundle on EuroParts Lanka product pages by 34%.


When to Add "use client"

Here is my rule: I never add `"use client"` preemptively. I write every component as a Server Component first. I only add the directive when the compiler tells me I need it — when I use a hook, attach an event handler, or import a client-only library.

The situations that genuinely require "use client":

Interactive Forms

tsx
"use client";

import { useActionState } from "react";
import { submitInquiry } from "@/app/actions";

export function InquiryForm({ partId }: { partId: string }) {
  const [state, formAction, isPending] = useActionState(submitInquiry, null);

  return (
    <form action={formAction}>
      <input type="hidden" name="partId" value={partId} />
      <textarea
        name="message"
        placeholder="Describe what you need..."
        required
        className="w-full rounded-lg border p-3"
      />
      <button
        type="submit"
        disabled={isPending}
        className="mt-3 rounded-lg bg-orange-500 px-6 py-2 text-white"
      >
        {isPending ? "Sending..." : "Send Inquiry"}
      </button>
      {state?.error && <p className="mt-2 text-red-500">{state.error}</p>}
      {state?.success && (
        <p className="mt-2 text-green-500">Inquiry sent successfully.</p>
      )}
    </form>
  );
}

Third-Party Libraries with Browser Dependencies

tsx
"use client";

import { motion } from "framer-motion";

export function AnimatedCard({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
      className="rounded-xl border bg-surface p-6"
    >
      {children}
    </motion.div>
  );
}

Browser API Access

tsx
"use client";

import { useEffect, useState } from "react";

export function OnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    function handleOnline() { setIsOnline(true); }
    function handleOffline() { setIsOnline(false); }

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  if (isOnline) return null;

  return (
    <div className="bg-red-500 px-4 py-2 text-center text-sm text-white">
      You are offline. Some features may not work.
    </div>
  );
}

Everything else stays on the server. No exceptions.


Common Mistakes

I have made all of these mistakes myself. Documenting them so you skip the learning curve.

Mistake 1: Marking an Entire Page as "use client"

This is the most common mistake. A developer needs one onClick handler and puts "use client" at the top of the page component. Every child component in that subtree now ships to the client. Extract the interactive piece into its own Client Component instead.

Mistake 2: Importing a Server-Only Module in a Client Component

If you import a database client or server-only utility in a Client Component, Next.js will throw a build error — or worse, try to bundle it for the browser. Use the server-only package to make this fail loudly:

tsx
// lib/database.ts
import "server-only";
import { PrismaClient } from "@prisma/client";

export const db = new PrismaClient();

Now if anyone accidentally imports @/lib/database in a Client Component, they get a clear build error instead of a cryptic runtime failure.

Mistake 3: Passing Non-Serializable Props

Server Components pass props to Client Components across a serialization boundary. Functions, classes, Dates, Maps, Sets — these cannot cross that boundary. Only plain objects, arrays, strings, numbers, booleans, null, and undefined can be passed.

tsx
// This will fail — you can't pass a function from Server to Client
<ClientComponent onSubmit={async (data) => { /* ... */ }} />

// Instead, use Server Actions
<ClientComponent submitAction={submitFormAction} />

Mistake 4: Fetching Data Sequentially

tsx
// BAD — sequential waterfall, each await blocks the next
const user = await getUser();
const orders = await getOrders(user.id);
const recommendations = await getRecommendations(user.id);

// GOOD — parallel fetching where possible
const user = await getUser();
const [orders, recommendations] = await Promise.all([
  getOrders(user.id),
  getRecommendations(user.id),
]);

On EuroParts Lanka, fixing a three-query waterfall on the dashboard page reduced the page load from 1.8 seconds to 0.7 seconds. Parallel fetching is not optional.

Mistake 5: Over-Granular Client Components

Going too far in the other direction is also a problem. If you have a form with 15 inputs, do not make each input a separate Client Component. The form is a single interactive unit — make the whole form one Client Component and pass it the initial data from a Server Component parent.


Performance Impact with Real Numbers

I track Core Web Vitals on every project I ship. Here are real numbers comparing Server Components to the traditional client-rendered approach.

EuroParts Lanka — Parts Catalog Page

MetricBefore RSC (Pages Router)After RSC (App Router)Change
Client JS Bundle187 KB (gzipped)74 KB (gzipped)-60%
LCP3.1s1.8s-42%
FID120ms45ms-63%
CLS0.120.03-75%
Time to Interactive4.2s2.1s-50%

iamuvin.com — Blog Article Page

MetricBefore RSCAfter RSCChange
Client JS Bundle142 KB38 KB-73%
LCP2.4s1.2s-50%
FID85ms22ms-74%
TTI3.1s1.4s-55%

The blog article pages saw the most dramatic improvement because articles are almost entirely static content. With Server Components, the only Client Components on a blog page are the table of contents (scroll tracking), the share buttons, and the newsletter form. Everything else — the article body, the header, the sidebar, the footer — is pure server-rendered HTML with zero JavaScript cost.

The parts catalog improved less dramatically because it has more interactive elements: search filtering, sort controls, add-to-cart buttons, quantity selectors. But even there, the 60% reduction in client JavaScript is significant. On mobile devices in Sri Lanka where many of our users access the site on 3G connections, that reduction translates directly into faster page loads and lower data costs.


Server Components and Streaming

Streaming is the natural companion to Server Components. When a Server Component needs to fetch slow data, you do not want the entire page to wait. Streaming lets you send the shell immediately and fill in the slow parts as they resolve.

tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { DashboardHeader } from "@/components/dashboard-header";
import { QuickStats } from "@/components/quick-stats";
import { RecentOrders } from "@/components/recent-orders";
import { AnalyticsChart } from "@/components/analytics-chart";
import { OrdersSkeleton } from "@/components/skeletons";
import { ChartSkeleton } from "@/components/skeletons";

export default function DashboardPage() {
  return (
    <div className="space-y-8">
      <DashboardHeader />

      {/* Fast — renders immediately */}
      <Suspense fallback={<div className="h-24 animate-pulse rounded-lg bg-surface" />}>
        <QuickStats />
      </Suspense>

      <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
        {/* Slower — streams in when ready */}
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>

        {/* Slowest — streams in last */}
        <Suspense fallback={<ChartSkeleton />}>
          <AnalyticsChart />
        </Suspense>
      </div>
    </div>
  );
}

Each Suspense boundary is an independent streaming point. The header renders instantly. The stats stream in as soon as their query resolves. The orders and analytics chart stream in independently — whichever finishes first appears first. The user sees a progressively loading page instead of staring at a blank screen.

On the EuroParts Lanka admin dashboard, streaming reduced perceived load time from 3.2 seconds (waiting for all data) to 0.8 seconds (shell + first data). The analytics chart, which queries aggregated data over six months, takes the longest at around 1.5 seconds — but by the time it loads, the admin has already started reading their recent orders.

Loading UI and Skeleton Patterns

I keep skeleton components alongside the components they represent:

components/
  recent-orders.tsx        # Server Component — fetches and renders orders
  skeletons/
    orders-skeleton.tsx    # Matches the visual layout of recent-orders
    chart-skeleton.tsx     # Matches the visual layout of analytics-chart

The skeleton must match the rendered layout exactly. If your skeleton is a different height than the actual content, you get a layout shift when the real content streams in — killing your CLS score. I use the same Tailwind classes for dimensions and spacing in both the skeleton and the real component.


My Component Architecture

After shipping multiple RSC projects, I have settled on an architecture that works consistently. Here is the structure I use on every project.

app/
  (routes)/
    page.tsx              # Server Component — data fetching + layout
    loading.tsx           # Streaming fallback for the route
    error.tsx             # Error boundary (Client Component by necessity)

components/
  server/                 # Explicitly server-only components
    part-card.tsx
    stats-grid.tsx
    article-body.tsx

  client/                 # Explicitly client components
    search-bar.tsx
    add-to-cart.tsx
    theme-toggle.tsx
    newsletter-form.tsx

  shared/                 # Pure presentational — work in both contexts
    badge.tsx
    avatar.tsx
    skeleton.tsx

lib/
  database.ts             # server-only imports
  auth.ts                 # server-only imports
  utils.ts                # shared utilities (no server/client deps)
  validations.ts          # Zod schemas (shared)

The key principle: page components are always Server Components. They handle data fetching and compose the page from server and client children. Client Components are small, focused, and receive only serializable props.

I enforce this with a simple convention. Every file in components/client/ starts with "use client". Every file in components/server/ imports "server-only". The shared/ directory contains pure presentational components that accept primitive props and work in either context.

For iamuvin.com, the breakdown looks like this:

  • 42 Server Components — article pages, portfolio items, service listings, navigation structure, footer, header, metadata
  • 11 Client Components — search, theme toggle, mobile menu, contact form, newsletter signup, code syntax highlighter, table of contents, share buttons, scroll-to-top, cookie consent, analytics
  • 8 Shared Components — badge, avatar, skeleton, separator, container, section heading, external link, icon wrapper

That is 80% server, 20% client. The client JavaScript bundle for the entire site is under 45 KB gzipped. For a site with a blog, portfolio, services page, and contact form — that is lean.


Key Takeaways

  1. Default to Server Components. Write every component as a Server Component first. Add "use client" only when the compiler demands it.
  1. Push the client boundary down. Never mark a parent as "use client" because a child needs interactivity. Extract the interactive part into its own Client Component.
  1. Fetch data in parallel. Use Promise.all for independent queries. Sequential awaits create waterfalls that negate RSC performance benefits.
  1. Use the children pattern. Pass Server Components as children to Client Components. The Server Component renders on the server even though it appears inside a Client Component.
  1. Guard server-only code. Use the server-only package on any module that touches databases, secrets, or internal APIs. Fail at build time, not runtime.
  1. Stream with Suspense. Wrap slow Server Components in Suspense boundaries with matching skeleton components. Users see progressive loading instead of blank screens.
  1. Only serializable props cross the boundary. No functions, no class instances, no Dates. Use Server Actions for server-side mutations from Client Components.
  1. Measure the impact. Track your client JS bundle size and Core Web Vitals before and after adopting RSC. The numbers will justify the learning curve.

Server Components are not an incremental improvement. They are a fundamental shift in how React applications are built. The mental model takes time to click — it took me a solid two months of building before the composition patterns became instinctive. But once they do, you will never want to go back to shipping 200 KB of JavaScript for a page that displays a list of products.

Start your next project with Server Components as the default. Push "use client" to the leaves. Let the server do what the server does best.


*Building a project with Server Components and need expert guidance? I help teams architect RSC-based applications for production. Check out my services or reach out directly.*


About the Author

Uvin Vindula (@IAMUVIN) is a full-stack Web3 and AI engineer based between Sri Lanka and the UK. He builds production-grade applications with Next.js, React, and Solidity — shipping everything from automotive parts platforms to DeFi protocols. Every project starts with Server Components by default.

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.