You are viewing a preview of this lesson. Sign in to start learning
Back to React

Full-Stack & Server Components

Master modern SSR frameworks and React 19 server features

Full-Stack & Server Components in React

Master full-stack React development with free flashcards and spaced repetition practice. This lesson covers React Server Components (RSC), the differences between client and server rendering, data fetching patterns, and how to build full-stack applications with modern Reactβ€”essential concepts for building performant, scalable web applications.

Welcome to Full-Stack React Development πŸš€

React has evolved beyond a client-side library into a full-stack framework. With the introduction of React Server Components, you can now build applications where some components render on the server and others on the client, creating a hybrid architecture that combines the best of both worlds. This paradigm shift enables better performance, smaller bundle sizes, and more intuitive data fetching patterns.

Understanding server components is crucial for modern React development, especially when working with frameworks like Next.js 13+ that embrace this architecture. Let's explore how to leverage server-side rendering, streaming, and server components to build lightning-fast applications.

Core Concepts πŸ’‘

What Are React Server Components?

React Server Components (RSC) are components that render exclusively on the server. Unlike traditional server-side rendering (SSR) where components render once on the server and then hydrate on the client, RSC never ship their code to the browser.

🧠 Memory Device: SERVER vs CLIENT

SERVER components = Secure data access, Exclusive backend logic, Reduced bundle size, Very fast initial load, Efficient caching, Reads from databases directly

CLIENT components = Click handlers, Listeners for events, Interactivity required, Effects (useEffect), Navigator APIs, Time-based updates (useState)

Server vs Client Components: The Key Differences

Feature Server Components πŸ–₯️ Client Components πŸ’»
Where they run Server only Server (for SSR) + Client
Bundle size impact Zero (code stays on server) Increases bundle (shipped to browser)
Data fetching Direct database/API access Must use fetch/API routes
Interactivity No hooks, no state, no events Full access to hooks and events
Secrets/credentials Can use safely (never exposed) Must avoid (shipped to client)
Re-rendering On navigation or refresh On state/prop changes

How to Declare Component Types

In React with frameworks like Next.js 13+, components are server components by default. You opt into client components explicitly:

// ServerComponent.js - Default (no directive needed)
export default function ServerComponent() {
  // Can access databases, file system, etc.
  const data = await database.query('SELECT * FROM users');
  return <div>{data.length} users</div>;
}
// ClientComponent.js - Must use 'use client' directive
'use client';

import { useState } from 'react';

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

⚠️ Critical rule: The 'use client' directive must be at the very top of the file, before any imports!

The Component Boundary Model

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         SERVER COMPONENT (Default)           β”‚
β”‚  βœ“ Can import other server components       β”‚
β”‚  βœ“ Can import client components             β”‚
β”‚  βœ“ Direct data access                       β”‚
β”‚  βœ— No hooks or interactivity                β”‚
β”‚                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  CLIENT COMPONENT ('use client')   β”‚    β”‚
β”‚  β”‚  βœ“ Can use hooks and state         β”‚    β”‚
β”‚  β”‚  βœ“ Can have event handlers         β”‚    β”‚
β”‚  β”‚  βœ— Cannot import server components β”‚    β”‚
β”‚  β”‚  βœ— Props from server must be       β”‚    β”‚
β”‚  β”‚    serializable (JSON-compatible)  β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ’‘ Pro Tip: Think of the server/client boundary like a one-way membrane. Data flows from server β†’ client, but you can't flow server-only code back up.

Data Fetching in Server Components

One of the biggest advantages of server components is async/await at the component level:

// In traditional React, this wouldn't work!
// In RSC, this is the recommended pattern
export default async function ProductList() {
  // This runs on the server, not in the browser
  const response = await fetch('https://api.example.com/products', {
    headers: { 'Authorization': `Bearer ${process.env.API_SECRET}` }
  });
  const products = await response.json();
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

πŸ”’ Security benefit: Notice how we can safely use process.env.API_SECRET because this code never reaches the browser!

Streaming and Suspense

Server components work seamlessly with React's Suspense for streaming HTML to the browser:

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<LoadingSkeleton />}>
        <SlowDataComponent />
      </Suspense>
      <Suspense fallback={<Spinner />}>
        <AnotherSlowComponent />
      </Suspense>
    </div>
  );
}

async function SlowDataComponent() {
  const data = await fetchSlowData();
  return <div>{data}</div>;
}
STREAMING RENDERING TIMELINE

0ms   ──► Send initial HTML (header, static content)
         β”‚
100ms ──► First Suspense resolves, stream chunk
         β”‚
250ms ──► Second Suspense resolves, stream chunk
         β”‚
300ms ──► Complete, client hydrates interactive parts

      βœ… User sees content progressively
      βœ… No waiting for slowest data
      βœ… Better perceived performance

Composition Patterns

Pattern 1: Server component wraps client component

// app/page.js (Server Component)
import ClientCounter from './ClientCounter';

export default async function Page() {
  const initialValue = await fetchInitialValue();
  
  return (
    <div>
      <h1>Welcome</h1>
      <ClientCounter initial={initialValue} />
    </div>
  );
}
// ClientCounter.js
'use client';
import { useState } from 'react';

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

Pattern 2: Passing server components as children

// ServerParent.js (Server)
import ClientWrapper from './ClientWrapper';
import ServerChild from './ServerChild';

export default function ServerParent() {
  return (
    <ClientWrapper>
      <ServerChild /> {/* This stays a server component! */}
    </ClientWrapper>
  );
}
// ClientWrapper.js
'use client';
export default function ClientWrapper({ children }) {
  return <div className="interactive-wrapper">{children}</div>;
}

🎯 Key insight: Even though ServerChild is rendered inside a client component, it maintains its server component nature because it's passed as children (a serialized slot).

Full-Stack Data Mutations

React Server Components integrate with Server Actions for mutations:

// app/actions.js
'use server';

import { db } from './database';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  
  await db.posts.create({ title, content });
  revalidatePath('/posts'); // Refresh the posts list
}
// app/new-post/page.js (Server Component)
import { createPost } from '../actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

πŸ’‘ This works without JavaScript! The form submits via HTML form submission, then progressively enhances with JS when available.

Examples with Explanations πŸ”

Example 1: Building a Product Catalog with Mixed Components

// app/products/page.js (Server Component)
import { db } from '@/lib/database';
import ProductCard from './ProductCard';
import FilterPanel from './FilterPanel';

export default async function ProductsPage({ searchParams }) {
  // Server component can directly query database
  const products = await db.product.findMany({
    where: {
      category: searchParams.category,
      price: { lte: searchParams.maxPrice }
    },
    include: { reviews: true }
  });
  
  const categories = await db.category.findMany();
  
  return (
    <div className="products-layout">
      <FilterPanel categories={categories} />
      <div className="product-grid">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}
// app/products/FilterPanel.js (Client Component)
'use client';

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

export default function FilterPanel({ categories }) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [selectedCategory, setSelectedCategory] = useState(
    searchParams.get('category') || 'all'
  );
  
  const handleCategoryChange = (category) => {
    setSelectedCategory(category);
    const params = new URLSearchParams(searchParams);
    params.set('category', category);
    router.push(`/products?${params.toString()}`);
  };
  
  return (
    <aside>
      <h3>Filter by Category</h3>
      {categories.map(cat => (
        <button
          key={cat.id}
          onClick={() => handleCategoryChange(cat.slug)}
          className={selectedCategory === cat.slug ? 'active' : ''}
        >
          {cat.name}
        </button>
      ))}
    </aside>
  );
}
// app/products/ProductCard.js (Server Component)
import AddToCartButton from './AddToCartButton';

export default async function ProductCard({ product }) {
  const averageRating = product.reviews.reduce(
    (sum, r) => sum + r.rating, 0
  ) / product.reviews.length;
  
  return (
    <article className="product-card">
      <img src={product.imageUrl} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <div>⭐ {averageRating.toFixed(1)} ({product.reviews.length} reviews)</div>
      <AddToCartButton productId={product.id} />
    </article>
  );
}
// app/products/AddToCartButton.js (Client Component)
'use client';

import { useState } from 'react';
import { addToCart } from '../actions';

export default function AddToCartButton({ productId }) {
  const [isAdding, setIsAdding] = useState(false);
  
  const handleClick = async () => {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  };
  
  return (
    <button onClick={handleClick} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Why this architecture works:

  • πŸ–₯️ ProductsPage is a server component that queries the database directly
  • πŸ’» FilterPanel is a client component because it needs useState and onClick handlers
  • πŸ–₯️ ProductCard stays a server component to calculate ratings server-side
  • πŸ’» AddToCartButton is a client component for interactivity
  • 🎯 Result: Minimal JavaScript shipped to browser, fast initial load, full interactivity where needed

Example 2: Nested Layouts with Data Dependencies

// app/dashboard/layout.js (Server Component)
import { auth } from '@/lib/auth';
import { db } from '@/lib/database';
import Sidebar from './Sidebar';

export default async function DashboardLayout({ children }) {
  const session = await auth();
  
  if (!session) {
    redirect('/login');
  }
  
  const user = await db.user.findUnique({
    where: { id: session.userId },
    include: { notifications: { where: { read: false } } }
  });
  
  return (
    <div className="dashboard-layout">
      <Sidebar user={user} unreadCount={user.notifications.length} />
      <main>{children}</main>
    </div>
  );
}
// app/dashboard/Sidebar.js (Client Component)
'use client';

import { useState } from 'react';
import Link from 'next/link';

export default function Sidebar({ user, unreadCount }) {
  const [isExpanded, setIsExpanded] = useState(true);
  
  return (
    <aside className={isExpanded ? 'expanded' : 'collapsed'}>
      <button onClick={() => setIsExpanded(!isExpanded)}>☰</button>
      <div className="user-info">
        <img src={user.avatar} alt={user.name} />
        {isExpanded && <span>{user.name}</span>}
      </div>
      <nav>
        <Link href="/dashboard">Dashboard</Link>
        <Link href="/dashboard/messages">
          Messages {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
        </Link>
        <Link href="/dashboard/settings">Settings</Link>
      </nav>
    </aside>
  );
}

Key pattern: The layout fetches data on the server (auth check, user data) then passes serializable props to the client component for interactivity. The authentication logic never reaches the browser.

Example 3: Parallel Data Fetching with Suspense

// app/analytics/page.js (Server Component)
import { Suspense } from 'react';
import RevenueChart from './RevenueChart';
import UserStats from './UserStats';
import RecentOrders from './RecentOrders';
import LoadingCard from './LoadingCard';

export default function AnalyticsPage() {
  return (
    <div className="analytics-grid">
      <Suspense fallback={<LoadingCard title="Revenue" />}>
        <RevenueChart />
      </Suspense>
      
      <Suspense fallback={<LoadingCard title="Users" />}>
        <UserStats />
      </Suspense>
      
      <Suspense fallback={<LoadingCard title="Orders" />}>
        <RecentOrders />
      </Suspense>
    </div>
  );
}
// app/analytics/RevenueChart.js (Server Component)
import { db } from '@/lib/database';
import ChartRenderer from './ChartRenderer';

export default async function RevenueChart() {
  // Simulate slow query
  await new Promise(resolve => setTimeout(resolve, 2000));
  
  const revenueData = await db.order.groupBy({
    by: ['createdAt'],
    _sum: { total: true },
    where: {
      createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
    }
  });
  
  return <ChartRenderer data={revenueData} type="revenue" />;
}
// app/analytics/UserStats.js (Server Component)
import { db } from '@/lib/database';

export default async function UserStats() {
  // This runs in parallel with RevenueChart!
  const [totalUsers, activeUsers, newUsers] = await Promise.all([
    db.user.count(),
    db.user.count({ where: { lastActive: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } }),
    db.user.count({ where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } })
  ]);
  
  return (
    <div className="stats-card">
      <h3>User Statistics</h3>
      <dl>
        <dt>Total Users</dt>
        <dd>{totalUsers.toLocaleString()}</dd>
        <dt>Active (7d)</dt>
        <dd>{activeUsers.toLocaleString()}</dd>
        <dt>New (7d)</dt>
        <dd>{newUsers.toLocaleString()}</dd>
      </dl>
    </div>
  );
}

Performance win: All three components fetch data in parallel. The page streams HTML as each Suspense boundary resolves, giving users progressive loading instead of waiting for the slowest query.

Example 4: Server Actions for Form Mutations

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

import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData) {
  const title = formData.get('title');
  const content = formData.get('content');
  const published = formData.get('published') === 'on';
  
  // Validation
  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' };
  }
  
  // Database mutation
  const post = await db.post.create({
    data: { title, content, published }
  });
  
  // Revalidate the posts list cache
  revalidatePath('/posts');
  
  // Redirect to the new post
  redirect(`/posts/${post.id}`);
}

export async function updatePost(postId, formData) {
  const updates = {
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'on'
  };
  
  await db.post.update({
    where: { id: postId },
    data: updates
  });
  
  revalidatePath(`/posts/${postId}`);
  revalidatePath('/posts');
  
  return { success: true };
}
// app/posts/new/page.js (Server Component)
import { createPost } from '@/app/actions/posts';
import SubmitButton from './SubmitButton';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" required />
      </div>
      
      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" rows={10} />
      </div>
      
      <div>
        <label>
          <input type="checkbox" name="published" />
          Publish immediately
        </label>
      </div>
      
      <SubmitButton />
    </form>
  );
}
// app/posts/new/SubmitButton.js (Client Component)
'use client';

import { useFormStatus } from 'react-dom';

export default function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

Modern form handling: Server Actions enable progressive enhancement. The form works without JavaScript (standard form submission), but when JS is available, it submits without a full page reload and shows pending state.

Common Mistakes ⚠️

Mistake 1: Using hooks in server components

❌ Wrong:

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

βœ… Right:

export default async function ServerComponent() {
  const data = await fetchData(); // Use async/await directly
  return <div>{data}</div>;
}

Why: Server components don't have a lifecycle or re-render mechanism. They execute once per request. Use async/await for data fetching instead of useEffect.

Mistake 2: Importing server components into client components

❌ Wrong:

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

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

βœ… Right:

// Parent.js (Server)
import ClientComponent from './ClientComponent';
import ServerComponent from './ServerComponent';

export default function Parent() {
  return (
    <ClientComponent>
      <ServerComponent /> {/* Pass as children */}
    </ClientComponent>
  );
}
// ClientComponent.js
'use client';
export default function ClientComponent({ children }) {
  return <div className="wrapper">{children}</div>;
}

Why: Client components can't import server components because the server code would need to be bundled for the browser. Instead, compose them from a parent server component.

Mistake 3: Passing non-serializable props to client components

❌ Wrong:

// Server component
export default function Parent() {
  const handleClick = () => console.log('clicked'); // Function!
  return <ClientChild onAction={handleClick} />; // Error!
}

βœ… Right:

// Server component
export default function Parent() {
  const data = { id: 1, name: 'Test' }; // Serializable object
  return <ClientChild data={data} />;
}

Why: Props passed from server to client components must be JSON-serializable (strings, numbers, objects, arrays). Functions, dates, and class instances don't serialize properly.

Mistake 4: Forgetting 'use client' directive

❌ Wrong:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0); // Error in server component!
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

βœ… Right:

'use client'; // Must be first line!

import { useState } from 'react';

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

Why: Without the directive, the component defaults to being a server component, which can't use hooks or event handlers.

Mistake 5: Over-using client components

❌ Wrong:

'use client';

export default function ProductList({ products }) {
  // This entire component is client-side now!
  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h3>{p.name}</h3>
          <button>Add to cart</button>
        </div>
      ))}
    </div>
  );
}

βœ… Right:

// ProductList.js (Server Component)
import AddToCartButton from './AddToCartButton';

export default function ProductList({ products }) {
  return (
    <div>
      {products.map(p => (
        <div key={p.id}>
          <h3>{p.name}</h3>
          <AddToCartButton productId={p.id} /> {/* Only this is client-side */}
        </div>
      ))}
    </div>
  );
}
// AddToCartButton.js (Client Component)
'use client';
export default function AddToCartButton({ productId }) {
  return <button onClick={() => addToCart(productId)}>Add to cart</button>;
}

Why: Keep the 'use client' boundary as deep as possible. Only make interactive parts client components to minimize bundle size.

Key Takeaways 🎯

  1. Server components are the default in modern React frameworks. They render on the server, never ship code to the browser, and can directly access backend resources.

  2. Use 'use client' sparingly at the top of files that need interactivity, hooks, or browser APIs. Keep the client boundary as small as possible.

  3. Composition over imports: Pass server components as children or props to client components rather than importing them directly.

  4. Async/await in components is the new data fetching paradigm. No more useEffect for initial data loading in server components.

  5. Suspense enables streaming: Wrap async server components in Suspense boundaries to stream HTML progressively and show loading states.

  6. Server Actions provide a modern, progressive-enhancement-friendly way to handle mutations without separate API routes.

  7. Props must be serializable when passing from server to client components. Stick to JSON-compatible data types.

  8. Security by default: Server components can safely use API keys and database credentials because the code never reaches the browser.

πŸ“‹ Quick Reference Card

Server Component Default, async/await, no hooks, direct DB access
Client Component 'use client' directive, hooks, event handlers
Composition Rule Server can import client, not vice versa
Props Rule Server β†’ Client props must be JSON-serializable
Data Fetching Server: async/await, Client: useEffect/SWR/React Query
Suspense Wrap async components for streaming & loading states
Server Actions 'use server' for form mutations & progressive enhancement
Bundle Impact Server: 0 KB, Client: full component code shipped

πŸ“š Further Study

  1. React Server Components RFC - https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md - The original proposal explaining the motivation and design

  2. Next.js App Router Documentation - https://nextjs.org/docs/app - Comprehensive guide to building with React Server Components in Next.js

  3. Server Actions Deep Dive - https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations - Detailed patterns for mutations and form handling