Server Components in React: A Deep Dive with Next.js 14

ReactJS, Next.js|OCTOBER 15, 2025|0 VIEWS
Understand React Server Components, their benefits, and how to build modern applications with Next.js 14 App Router

Introduction

React Server Components (RSC) represent one of the most significant paradigm shifts in React's history. Introduced as an experimental feature and now stable in Next.js 14, Server Components fundamentally change how we think about building React applications by blurring the line between server and client.

Traditional React applications are entirely client-side: the server sends JavaScript bundles to the browser, which then executes React code to render the UI. This approach works but comes with trade-offs: large bundle sizes, slower initial page loads, and limited direct access to backend resources.

React Server Components solve these problems by allowing components to render on the server, with zero JavaScript sent to the client for those components. This results in:

  • Smaller Bundle Sizes: Server Components don't contribute to the client JavaScript bundle
  • Direct Backend Access: Query databases, read files, and access server-only resources directly
  • Improved Performance: Faster initial page loads and better Core Web Vitals
  • Automatic Code Splitting: Built-in optimization without manual intervention
  • Enhanced Security: Keep sensitive logic and API keys on the server

In this comprehensive guide, we'll explore React Server Components in depth using Next.js 14's App Router. We'll cover the fundamentals, best practices, common patterns, and build real-world examples.

Understanding React Server Components

The Mental Model

React Server Components introduce a new mental model where your component tree can have components rendering in different environments:

┌─────────────────────────────────────┐
│         Your App                     │
│                                      │
│  ┌────────────────────────────┐    │
│  │  Server Component (RSC)    │    │
│  │  • Runs on server          │    │
│  │  • No JS sent to client    │    │
│  │  • Can access backend      │    │
│  │                             │    │
│  │  ┌──────────────────────┐  │    │
│  │  │ Client Component     │  │    │
│  │  │ • Runs in browser    │  │    │
│  │  │ • Interactive        │  │    │
│  │  │ • Has state/effects  │  │    │
│  │  └──────────────────────┘  │    │
│  └────────────────────────────┘    │
└─────────────────────────────────────┘

Key Concepts:

  1. Server Components (default): Run only on the server during build or request time
  2. Client Components ('use client'): Run on both server (for SSR) and client
  3. Composition: Server Components can import Client Components, but not vice versa
  4. Data Flow: Props flow from Server to Client Components

Server Components vs Client Components

| Aspect | Server Components | Client Components | |--------|-------------------|-------------------| | Directive | None (default) | 'use client' | | Where they run | Server only | Server (SSR) + Client | | Bundle size | 0 KB | Included in bundle | | Data fetching | Direct (async/await) | useEffect/libraries | | Browser APIs | ❌ No | ✅ Yes | | Hooks | ❌ No | ✅ Yes | | Event handlers | ❌ No | ✅ Yes | | Backend access | ✅ Yes | ❌ No | | Re-rendering | On navigation/refresh | On state/prop changes |

When to Use Each

Use Server Components for:

  • Data fetching: Querying databases, calling APIs
  • Backend logic: Processing data, file operations
  • Static content: Blog posts, documentation
  • Large dependencies: Using heavy libraries without client impact
  • Security: Keeping sensitive tokens/keys secure

Use Client Components for:

  • Interactivity: Buttons, forms, event handlers
  • State management: useState, useReducer, useContext
  • Effects: useEffect, useLayoutEffect
  • Browser APIs: localStorage, geolocation, etc.
  • Hooks: Any React hook (custom or built-in)
  • Class components: All class components are client

Setting Up Next.js 14 with App Router

Project Initialization

Let's create a new Next.js 14 project with TypeScript:

# Create new Next.js app
npx create-next-app@latest server-components-demo --typescript --tailwind --app --no-src-dir

cd server-components-demo

# Install additional dependencies
npm install @prisma/client
npm install -D prisma

Answer the prompts:

  • ✅ TypeScript: Yes
  • ✅ ESLint: Yes
  • ✅ Tailwind CSS: Yes
  • app/ directory: Yes
  • src/ directory: No (optional)
  • ✅ Import alias: Yes (@/*)

Project Structure

server-components-demo/
├── app/
│   ├── api/
│   │   └── route.ts
│   ├── components/
│   │   ├── ServerComponent.tsx
│   │   └── ClientComponent.tsx
│   ├── dashboard/
│   │   ├── page.tsx
│   │   └── layout.tsx
│   ├── layout.tsx
│   └── page.tsx
├── lib/
│   ├── db.ts
│   └── utils.ts
├── public/
├── next.config.js
├── package.json
└── tsconfig.json

App Router Basics

In Next.js 14's App Router, the file system determines your routes:

app/
├── page.tsx              → / (homepage)
├── about/
│   └── page.tsx          → /about
├── blog/
│   ├── page.tsx          → /blog
│   └── [slug]/
│       └── page.tsx      → /blog/[slug]
└── dashboard/
    ├── layout.tsx        → Layout for /dashboard/*
    └── page.tsx          → /dashboard

Building with Server Components

Basic Server Component

Server Components are the default in the App Router. Here's a simple example:

// app/posts/page.tsx
// No 'use client' directive = Server Component

interface Post {
  id: number;
  title: string;
  content: string;
  author: string;
  createdAt: Date;
}

async function getPosts(): Promise<Post[]> {
  // Direct database access - no API route needed!
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store', // or 'force-cache', or { next: { revalidate: 3600 } }
  });
  
  if (!res.ok) throw new Error('Failed to fetch posts');
  
  return res.json();
}

export default async function PostsPage() {
  // Server Components can be async!
  const posts = await getPosts();
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Blog Posts</h1>
      <div className="grid gap-6">
        {posts.map((post) => (
          <article key={post.id} className="border rounded-lg p-6">
            <h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600 mb-4">{post.content}</p>
            <div className="flex justify-between text-sm text-gray-500">
              <span>By {post.author}</span>
              <span>{new Date(post.createdAt).toLocaleDateString()}</span>
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

Direct Database Access

One of the most powerful features of Server Components is direct database access:

// lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  });

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// app/users/page.tsx
import { prisma } from '@/lib/db';

interface User {
  id: string;
  name: string;
  email: string;
  posts: Array<{ id: string; title: string }>;
}

async function getUsers(): Promise<User[]> {
  // Direct Prisma query - no API route needed!
  return await prisma.user.findMany({
    include: {
      posts: {
        take: 5,
        orderBy: { createdAt: 'desc' },
      },
    },
  });
}

export default async function UsersPage() {
  const users = await getUsers();
  
  return (
    <div className="container mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">Users</h1>
      <div className="grid gap-4">
        {users.map((user) => (
          <div key={user.id} className="border rounded-lg p-6">
            <h2 className="text-xl font-semibold">{user.name}</h2>
            <p className="text-gray-600">{user.email}</p>
            <div className="mt-4">
              <h3 className="font-semibold mb-2">Recent Posts:</h3>
              <ul className="list-disc list-inside">
                {user.posts.map((post) => (
                  <li key={post.id}>{post.title}</li>
                ))}
              </ul>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Data Fetching Patterns

Parallel Data Fetching

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

async function getAnalytics() {
  const res = await fetch('https://api.example.com/analytics');
  return res.json();
}

export default async function DashboardPage() {
  // Parallel fetching - all three requests fire simultaneously
  const [user, posts, analytics] = await Promise.all([
    getUser(),
    getPosts(),
    getAnalytics(),
  ]);
  
  return (
    <div className="grid grid-cols-3 gap-4">
      <UserCard user={user} />
      <PostsList posts={posts} />
      <AnalyticsPanel data={analytics} />
    </div>
  );
}

Sequential Data Fetching

// app/post/[id]/page.tsx
async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`);
  return res.json();
}

async function getComments(postId: string) {
  const res = await fetch(`https://api.example.com/posts/${postId}/comments`);
  return res.json();
}

export default async function PostPage({ params }: { params: { id: string } }) {
  // Sequential: get post first
  const post = await getPost(params.id);
  
  // Then get comments using post data
  const comments = await getComments(post.id);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      <CommentsSection comments={comments} />
    </article>
  );
}

Client Components: Adding Interactivity

Creating a Client Component

To make a component interactive, add the 'use client' directive:

// app/components/LikeButton.tsx
'use client';

import { useState } from 'react';

interface LikeButtonProps {
  postId: string;
  initialLikes: number;
}

export default function LikeButton({ postId, initialLikes }: LikeButtonProps) {
  const [likes, setLikes] = useState(initialLikes);
  const [isLiked, setIsLiked] = useState(false);
  
  const handleLike = async () => {
    setIsLiked(!isLiked);
    setLikes(isLiked ? likes - 1 : likes + 1);
    
    // Call API to persist the like
    await fetch(`/api/posts/${postId}/like`, {
      method: 'POST',
      body: JSON.stringify({ liked: !isLiked }),
    });
  };
  
  return (
    <button
      onClick={handleLike}
      className={`px-4 py-2 rounded ${
        isLiked ? 'bg-blue-500 text-white' : 'bg-gray-200'
      }`}
    >
      ❤️ {likes} Likes
    </button>
  );
}

Composing Server and Client Components

Server Components can render Client Components:

// app/posts/[id]/page.tsx (Server Component)
import { prisma } from '@/lib/db';
import LikeButton from '@/app/components/LikeButton';
import CommentForm from '@/app/components/CommentForm';

async function getPost(id: string) {
  return await prisma.post.findUnique({
    where: { id },
    include: { author: true },
  });
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  
  if (!post) return <div>Post not found</div>;
  
  return (
    <article className="max-w-3xl mx-auto p-8">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <p className="text-gray-600 mb-8">By {post.author.name}</p>
      
      <div className="prose mb-8">{post.content}</div>
      
      {/* Client Component for interactivity */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
      
      {/* Another Client Component */}
      <CommentForm postId={post.id} />
    </article>
  );
}

Client Component Patterns

Lifting Client Components Up

Instead of making a whole component client-side, extract interactive parts:

// ❌ BAD: Entire component is client-side
'use client';

export default function ProductPage({ product }) {
  const [count, setCount] = useState(1);
  
  return (
    <div>
      <ProductDetails product={product} /> {/* Doesn't need client JS */}
      <Reviews reviews={product.reviews} /> {/* Doesn't need client JS */}
      <AddToCart count={count} setCount={setCount} /> {/* Needs interactivity */}
    </div>
  );
}
// ✅ GOOD: Only interactive part is client-side

// app/products/[id]/page.tsx (Server Component)
import { getProduct } from '@/lib/products';
import AddToCart from '@/components/AddToCart';

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  
  return (
    <div>
      <ProductDetails product={product} /> {/* Server Component */}
      <Reviews reviews={product.reviews} /> {/* Server Component */}
      <AddToCart productId={product.id} /> {/* Client Component */}
    </div>
  );
}

// components/AddToCart.tsx (Client Component)
'use client';

export default function AddToCart({ productId }) {
  const [count, setCount] = useState(1);
  // ... interactive logic
}

Passing Server Components as Props

You can pass Server Components as children to Client Components:

// components/ClientWrapper.tsx
'use client';

import { ReactNode } from 'react';

export default function ClientWrapper({ children }: { children: ReactNode }) {
  // Client-side logic here
  return <div className="wrapper">{children}</div>;
}

// app/page.tsx (Server Component)
import ClientWrapper from '@/components/ClientWrapper';
import ServerContent from '@/components/ServerContent';

export default function Page() {
  return (
    <ClientWrapper>
      {/* This remains a Server Component! */}
      <ServerContent />
    </ClientWrapper>
  );
}

Advanced Patterns and Techniques

Streaming and Suspense

Server Components work seamlessly with React Suspense for streaming:

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function SlowComponent() {
  // Simulate slow data fetch
  await new Promise((resolve) => setTimeout(resolve, 3000));
  const data = await fetch('https://api.example.com/slow');
  const result = await data.json();
  
  return <div>{result.content}</div>;
}

function LoadingSpinner() {
  return <div className="animate-spin"></div>;
}

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Fast content renders immediately */}
      <QuickStats />
      
      {/* Slow content streams in later */}
      <Suspense fallback={<LoadingSpinner />}>
        <SlowComponent />
      </Suspense>
      
      <Suspense fallback={<LoadingSpinner />}>
        <AnotherSlowComponent />
      </Suspense>
    </div>
  );
}

Loading States

Next.js provides a special loading.tsx file for route-level loading states:

// app/posts/loading.tsx
export default function Loading() {
  return (
    <div className="container mx-auto p-8">
      <div className="animate-pulse">
        <div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
        <div className="space-y-3">
          <div className="h-4 bg-gray-200 rounded"></div>
          <div className="h-4 bg-gray-200 rounded"></div>
          <div className="h-4 bg-gray-200 rounded w-5/6"></div>
        </div>
      </div>
    </div>
  );
}

Error Handling

Use error.tsx for error boundaries:

// app/posts/error.tsx
'use client'; // Error components must be Client Components

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="container mx-auto p-8">
      <h2 className="text-2xl font-bold text-red-600 mb-4">
        Something went wrong!
      </h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={() => reset()}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        Try again
      </button>
    </div>
  );
}

Data Caching and Revalidation

Next.js extends fetch with caching options:

// Static data - cached indefinitely
async function getStaticData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache', // Default
  });
  return res.json();
}

// Dynamic data - never cached
async function getDynamicData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store',
  });
  return res.json();
}

// Time-based revalidation
async function getRevalidatedData() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }, // Revalidate every hour
  });
  return res.json();
}

// On-demand revalidation
// In an API route:
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST() {
  revalidatePath('/posts'); // Revalidate specific path
  revalidateTag('posts'); // Revalidate by tag
  return Response.json({ revalidated: true });
}

Server Actions

Server Actions allow you to mutate data directly from components:

// app/posts/actions.ts
'use server';

import { prisma } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  await prisma.post.create({
    data: { title, content, authorId: 'user-id' },
  });
  
  revalidatePath('/posts');
}

export async function deletePost(postId: string) {
  await prisma.post.delete({
    where: { id: postId },
  });
  
  revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '../actions';

export default function NewPostPage() {
  return (
    <form action={createPost} className="max-w-2xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Create New Post</h1>
      
      <div className="mb-4">
        <label className="block mb-2 font-semibold">Title</label>
        <input
          type="text"
          name="title"
          required
          className="w-full px-4 py-2 border rounded"
        />
      </div>
      
      <div className="mb-4">
        <label className="block mb-2 font-semibold">Content</label>
        <textarea
          name="content"
          required
          rows={10}
          className="w-full px-4 py-2 border rounded"
        />
      </div>
      
      <button
        type="submit"
        className="px-6 py-2 bg-blue-500 text-white rounded"
      >
        Create Post
      </button>
    </form>
  );
}

With client-side handling:

// components/DeleteButton.tsx
'use client';

import { useTransition } from 'react';
import { deletePost } from '@/app/posts/actions';

export default function DeleteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();
  
  return (
    <button
      onClick={() => startTransition(() => deletePost(postId))}
      disabled={isPending}
      className="px-4 py-2 bg-red-500 text-white rounded disabled:opacity-50"
    >
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  );
}

Real-World Example: Blog Platform

Let's build a complete blog platform showcasing Server Components:

Database Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  avatar    String?
  posts     Post[]
  comments  Comment[]
  createdAt DateTime @default(now())
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String
  excerpt     String
  published   Boolean   @default(false)
  views       Int       @default(0)
  likes       Int       @default(0)
  author      User      @relation(fields: [authorId], references: [id])
  authorId    String
  comments    Comment[]
  tags        Tag[]
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  post      Post     @relation(fields: [postId], references: [id])
  postId    String
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  createdAt DateTime @default(now())
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

Home Page

// app/page.tsx
import { prisma } from '@/lib/db';
import Link from 'next/link';
import { Suspense } from 'react';

async function getFeaturedPosts() {
  return await prisma.post.findMany({
    where: { published: true },
    include: {
      author: true,
      tags: true,
      _count: { select: { comments: true } },
    },
    orderBy: { views: 'desc' },
    take: 6,
  });
}

async function FeaturedPosts() {
  const posts = await getFeaturedPosts();
  
  return (
    <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
      {posts.map((post) => (
        <Link
          key={post.id}
          href={`/blog/${post.slug}`}
          className="border rounded-lg overflow-hidden hover:shadow-lg transition"
        >
          <div className="p-6">
            <h2 className="text-xl font-bold mb-2">{post.title}</h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            
            <div className="flex items-center justify-between text-sm">
              <span className="text-gray-500">
                By {post.author.name}
              </span>
              <div className="flex gap-4 text-gray-500">
                <span>👁️ {post.views}</span>
                <span>❤️ {post.likes}</span>
                <span>💬 {post._count.comments}</span>
              </div>
            </div>
            
            <div className="flex gap-2 mt-4">
              {post.tags.map((tag) => (
                <span
                  key={tag.id}
                  className="px-2 py-1 bg-blue-100 text-blue-600 text-xs rounded"
                >
                  {tag.name}
                </span>
              ))}
            </div>
          </div>
        </Link>
      ))}
    </div>
  );
}

function FeaturedPostsSkeleton() {
  return (
    <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
      {[...Array(6)].map((_, i) => (
        <div key={i} className="border rounded-lg p-6 animate-pulse">
          <div className="h-6 bg-gray-200 rounded mb-2"></div>
          <div className="h-4 bg-gray-200 rounded mb-4"></div>
          <div className="h-4 bg-gray-200 rounded w-2/3"></div>
        </div>
      ))}
    </div>
  );
}

export default function HomePage() {
  return (
    <main className="container mx-auto px-4 py-12">
      <section className="text-center mb-16">
        <h1 className="text-5xl font-bold mb-4">Welcome to Our Blog</h1>
        <p className="text-xl text-gray-600">
          Discover amazing stories and insights
        </p>
      </section>
      
      <section>
        <h2 className="text-3xl font-bold mb-8">Featured Posts</h2>
        <Suspense fallback={<FeaturedPostsSkeleton />}>
          <FeaturedPosts />
        </Suspense>
      </section>
    </main>
  );
}

Blog Post Page

// app/blog/[slug]/page.tsx
import { prisma } from '@/lib/db';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import LikeButton from '@/components/LikeButton';
import CommentSection from '@/components/CommentSection';
import ViewTracker from '@/components/ViewTracker';

async function getPost(slug: string) {
  const post = await prisma.post.findUnique({
    where: { slug, published: true },
    include: {
      author: true,
      tags: true,
    },
  });
  
  if (!post) return null;
  
  // Increment view count
  await prisma.post.update({
    where: { id: post.id },
    data: { views: { increment: 1 } },
  });
  
  return post;
}

async function getComments(postId: string) {
  return await prisma.comment.findMany({
    where: { postId },
    include: { author: true },
    orderBy: { createdAt: 'desc' },
  });
}

async function getRelatedPosts(postId: string, tags: string[]) {
  return await prisma.post.findMany({
    where: {
      id: { not: postId },
      published: true,
      tags: {
        some: {
          id: { in: tags },
        },
      },
    },
    take: 3,
    include: { author: true },
  });
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  
  if (!post) {
    return {
      title: 'Post Not Found',
    };
  }
  
  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
  };
}

async function Comments({ postId }: { postId: string }) {
  const comments = await getComments(postId);
  
  return (
    <div className="space-y-4">
      <h3 className="text-2xl font-bold">
        Comments ({comments.length})
      </h3>
      {comments.map((comment) => (
        <div key={comment.id} className="border-l-4 border-blue-500 pl-4">
          <div className="flex items-center gap-2 mb-2">
            <span className="font-semibold">{comment.author.name}</span>
            <span className="text-sm text-gray-500">
              {new Date(comment.createdAt).toLocaleDateString()}
            </span>
          </div>
          <p className="text-gray-700">{comment.content}</p>
        </div>
      ))}
    </div>
  );
}

async function RelatedPosts({ postId, tagIds }: { postId: string; tagIds: string[] }) {
  const related = await getRelatedPosts(postId, tagIds);
  
  if (related.length === 0) return null;
  
  return (
    <div className="mt-12">
      <h3 className="text-2xl font-bold mb-6">Related Posts</h3>
      <div className="grid md:grid-cols-3 gap-4">
        {related.map((post) => (
          <a
            key={post.id}
            href={`/blog/${post.slug}`}
            className="border rounded-lg p-4 hover:shadow-lg transition"
          >
            <h4 className="font-semibold mb-2">{post.title}</h4>
            <p className="text-sm text-gray-600">{post.excerpt}</p>
          </a>
        ))}
      </div>
    </div>
  );
}

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  
  if (!post) {
    notFound();
  }
  
  return (
    <article className="container mx-auto px-4 py-12 max-w-4xl">
      <header className="mb-8">
        <h1 className="text-5xl font-bold mb-4">{post.title}</h1>
        
        <div className="flex items-center gap-4 text-gray-600 mb-4">
          <div className="flex items-center gap-2">
            <span className="font-semibold">{post.author.name}</span>
          </div>
          <span></span>
          <time>{new Date(post.createdAt).toLocaleDateString()}</time>
          <span></span>
          <span>👁️ {post.views} views</span>
        </div>
        
        <div className="flex gap-2">
          {post.tags.map((tag) => (
            <span
              key={tag.id}
              className="px-3 py-1 bg-blue-100 text-blue-600 rounded-full text-sm"
            >
              {tag.name}
            </span>
          ))}
        </div>
      </header>
      
      <div className="prose prose-lg max-w-none mb-12">
        {post.content}
      </div>
      
      <div className="border-t pt-8 mb-12">
        <LikeButton postId={post.id} initialLikes={post.likes} />
      </div>
      
      <Suspense fallback={<div>Loading comments...</div>}>
        <Comments postId={post.id} />
      </Suspense>
      
      <CommentSection postId={post.id} />
      
      <Suspense fallback={<div>Loading related posts...</div>}>
        <RelatedPosts postId={post.id} tagIds={post.tags.map(t => t.id)} />
      </Suspense>
    </article>
  );
}

Comment Form Component

// components/CommentSection.tsx
'use client';

import { useState, useTransition } from 'react';
import { submitComment } from '@/app/actions';

export default function CommentSection({ postId }: { postId: string }) {
  const [content, setContent] = useState('');
  const [isPending, startTransition] = useTransition();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    startTransition(async () => {
      await submitComment(postId, content);
      setContent('');
    });
  };
  
  return (
    <div className="mt-12">
      <h3 className="text-2xl font-bold mb-4">Leave a Comment</h3>
      <form onSubmit={handleSubmit} className="space-y-4">
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="Share your thoughts..."
          rows={4}
          required
          className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          disabled={isPending}
          className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
        >
          {isPending ? 'Posting...' : 'Post Comment'}
        </button>
      </form>
    </div>
  );
}

Performance Optimization

Bundle Size Analysis

Server Components dramatically reduce bundle size:

// Before: Everything is client-side
'use client';

import { format } from 'date-fns'; // ~70KB
import { marked } from 'marked';    // ~45KB
import Prism from 'prismjs';        // ~30KB

export default function BlogPost({ post }) {
  // ~145KB added to client bundle
  const formatted = format(new Date(post.date), 'MMMM dd, yyyy');
  const html = marked(post.content);
  // ...
}
// After: Heavy processing on server
// No 'use client' = Server Component

import { format } from 'date-fns'; // 0KB to client
import { marked } from 'marked';    // 0KB to client
import Prism from 'prismjs';        // 0KB to client

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  
  // All heavy processing happens on server
  const formatted = format(new Date(post.date), 'MMMM dd, yyyy');
  const html = marked(post.content);
  
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Streaming with Suspense Boundaries

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      {/* Fast content shows immediately */}
      <div className="col-span-2">
        <h1>Dashboard</h1>
      </div>
      
      {/* Each section streams independently */}
      <Suspense fallback={<Skeleton />}>
        <RevenueChart />
      </Suspense>
      
      <Suspense fallback={<Skeleton />}>
        <UserStats />
      </Suspense>
      
      <Suspense fallback={<Skeleton />}>
        <RecentOrders />
      </Suspense>
      
      <Suspense fallback={<Skeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

Caching Strategies

// lib/cache.ts
import { unstable_cache } from 'next/cache';

// Cache function results
export const getCachedPosts = unstable_cache(
  async () => {
    return await prisma.post.findMany({
      where: { published: true },
      include: { author: true },
    });
  },
  ['posts-list'],
  {
    revalidate: 3600, // 1 hour
    tags: ['posts'],
  }
);

// Use in component
export default async function PostsList() {
  const posts = await getCachedPosts();
  // ...
}

Best Practices and Common Pitfalls

Do's and Don'ts

✅ DO:

  1. Use Server Components by default - Only use Client Components when needed
  2. Fetch data where it's needed - Colocate data fetching with components
  3. Use Suspense for loading states - Better UX with streaming
  4. Keep sensitive logic on server - API keys, business logic
  5. Minimize Client Component boundaries - Lift them as high as possible
  6. Use Server Actions for mutations - Simpler than API routes

❌ DON'T:

  1. Don't add 'use client' unnecessarily - Increases bundle size
  2. Don't import Server Components into Client Components - Won't work
  3. Don't use hooks in Server Components - They're not supported
  4. Don't forget to handle errors - Use error.tsx files
  5. Don't over-fetch data - Be mindful of waterfall requests
  6. Don't ignore caching - Configure fetch caching appropriately

Common Mistakes

Mistake 1: Importing Server Components into Client Components

// ❌ WRONG
'use client';

import ServerComponent from './ServerComponent'; // Error!

export default function ClientComponent() {
  return <ServerComponent />;
}

// ✅ RIGHT - Pass as children
// ClientComponent.tsx
'use client';

export default function ClientComponent({ children }) {
  return <div className="wrapper">{children}</div>;
}

// page.tsx
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';

export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

Mistake 2: Using Hooks in Server Components

// ❌ WRONG
import { useState } from 'react';

export default async function ServerComponent() {
  const [count, setCount] = useState(0); // Error!
  const data = await fetchData();
  return <div>{data}</div>;
}

// ✅ RIGHT - Extract interactive part
export default async function ServerComponent() {
  const data = await fetchData();
  
  return (
    <div>
      <div>{data}</div>
      <InteractiveCounter /> {/* Client Component */}
    </div>
  );
}

// InteractiveCounter.tsx
'use client';

export default function InteractiveCounter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Mistake 3: Unnecessary Client Components

// ❌ WRONG - Making everything client-side
'use client';

export default function PostPage({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton postId={post.id} />
    </article>
  );
}

// ✅ RIGHT - Only interactive part is client-side
export default async function PostPage({ params }) {
  const post = await getPost(params.slug);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton postId={post.id} /> {/* Only this is client */}
    </article>
  );
}

Testing Server Components

Unit Testing

// __tests__/PostsList.test.tsx
import { render, screen } from '@testing-library/react';
import PostsList from '@/app/components/PostsList';

// Mock Prisma
jest.mock('@/lib/db', () => ({
  prisma: {
    post: {
      findMany: jest.fn().mockResolvedValue([
        {
          id: '1',
          title: 'Test Post',
          content: 'Test content',
          author: { name: 'John Doe' },
        },
      ]),
    },
  },
}));

describe('PostsList', () => {
  it('renders posts', async () => {
    const PostsListResolved = await PostsList();
    render(PostsListResolved);
    
    expect(screen.getByText('Test Post')).toBeInTheDocument();
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

Integration Testing

// __tests__/integration/blog.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createPost } from '@/app/actions';

describe('Blog Integration', () => {
  it('creates and displays a post', async () => {
    const formData = new FormData();
    formData.append('title', 'New Post');
    formData.append('content', 'Post content');
    
    await createPost(formData);
    
    // Verify post was created
    // ... assertions
  });
});

Migration Guide

Migrating from Pages Router

Before (Pages Router):

// pages/posts/[id].tsx
import { GetServerSideProps } from 'next';

interface Props {
  post: Post;
}

export default function PostPage({ post }: Props) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const post = await getPost(params.id as string);
  
  return {
    props: { post },
  };
};

After (App Router with Server Components):

// app/posts/[id]/page.tsx
async function getPost(id: string) {
  // Direct data fetching in component
  return await prisma.post.findUnique({
    where: { id },
  });
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

Key Differences:

  1. No more getServerSideProps - fetch directly in component
  2. Components can be async
  3. Data fetching is colocated with component
  4. No props passing from special functions

Conclusion

React Server Components represent a fundamental shift in how we build React applications. By moving computation and data fetching to the server, we can build faster, more efficient applications with better user experiences.

Key Takeaways:

  1. Server Components are the default in Next.js 14 App Router - use them unless you need interactivity
  2. Smaller bundles - Server Components don't add to client JavaScript
  3. Direct backend access - Query databases, call APIs, read files directly
  4. Better performance - Faster initial loads, streaming, automatic code splitting
  5. Compose carefully - Server Components can render Client Components, but not vice versa
  6. Use Server Actions - Simplify mutations without API routes
  7. Embrace Suspense - Stream content for better perceived performance

When to Use Server Components:

  • ✅ Data fetching and display
  • ✅ Backend logic and processing
  • ✅ Static content
  • ✅ Large dependencies
  • ✅ Security-sensitive operations

When to Use Client Components:

  • ✅ Interactivity (onClick, onChange, etc.)
  • ✅ State management (useState, useContext)
  • ✅ Effects (useEffect, useLayoutEffect)
  • ✅ Browser APIs (localStorage, geolocation)
  • ✅ Custom hooks

Server Components are not just a new feature - they're a new way of thinking about React applications. By understanding their mental model and following best practices, you can build applications that are faster, more maintainable, and provide better user experiences.

The future of React is server-first, and Next.js 14 makes it easier than ever to take advantage of this paradigm shift. Start building with Server Components today and experience the difference they make.

Resources

Happy coding! 🚀