Next.js Tutorial: How to Use Server and Client Components

Next.js is a robust React framework that supports both server-side and client-side rendering. With the release of Next.js 13, developers gained the ability to use Server and Client Components more efficiently. In this tutorial, you’ll learn how to use both types of components to build fast, scalable applications.

What Are Server and Client Components?

Let’s start with a simple distinction.

  • Server Components run only on the server. They don’t ship any JavaScript to the client.
  • Client Components run in the browser. They are used when interactivity is required.

This separation helps you optimize your app for performance. You use server components for static or dynamic rendering without client-side overhead. You use client components when you need state, effects, or user events.

Why It Matters

Before Next.js 13, everything in your React app had to be client-side. This often led to large JavaScript bundles. Now, you can split logic cleanly between server and client, which reduces load time and improves performance.

Setting Up Your Next.js Project

To begin, create a Next.js app:

npx create-next-app@latest my-next-app
cd my-next-app

Ensure that you enable the App Router by selecting it during the setup process.

Creating Server Components

By default, components in the app/ directory are Server Components. That means unless you mark them otherwise, they won’t ship to the browser.

Example: Display Data from a Database

Create a file: app/products/page.tsx

import { getProducts } from '@/lib/products';

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Notice that getProducts() is an asynchronous function. This works because Server Components support async/await natively.

Advantages

  • Faster page loads
  • Smaller bundle sizes
  • Access to secure resources (e.g., databases, secrets)

You avoid exposing sensitive logic to the frontend, which is a huge security win.

Creating Client Components

Sometimes, you need interactivity, such as handling a button click. In that case, you need a Client Component.

Example: A Simple Counter

Create a file: components/Counter.tsx

'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}

Add the counter to your page:

import Counter from '@/components/Counter';

export default function HomePage() {
  return (
    <div>
      <h1>Welcome</h1>
      <Counter />
    </div>
  );
}

Key Point

Don’t forget the 'use client'; directive at the top. Without it, React will treat this as a Server Component and throw an error when it encounters useState.

Mixing Server and Client Components

You can combine both types easily. A Server Component can include a Client Component, but not the other way around.

Example: Server Component with Client Interaction

// app/page.tsx
import Counter from '@/components/Counter';

export default function Home() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Counter />
    </main>
  );
}

This setup is ideal. You keep most of your code on the server-side and interactive components on the client-side.

Common Use Cases

Server vs Client Component Decision Matrix

Best Practices

To get the most out of Server and Client Components, follow these best practices:

1. Minimize Client Components

Client Components add bundle size. Use them only when necessary. Whenever possible, keep logic on the server.

2. Separate Components by Purpose

Don’t mix client and server logic in one file. Keep them modular. For example:

// Server: app/products/page.tsx
// Client: components/CartButton.tsx

3. Avoid Passing Functions to Server Components

Server Components can’t handle functions as props from Client Components. Instead, structure your logic carefully and keep interactivity inside Client Components.

4. Use Suspense for Loading States

Suspense works seamlessly with Server Components.

import { Suspense } from 'react';
import ProductList from './ProductList';

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProductList />
    </Suspense>
  );
}

Suspense provides a smoother user experience by eliminating the need for complex loading logic.

Complete example of a small Next.js website

Folder Structure

/app
  /page.tsx
/components
  /ProductList.tsx         ← Server Component
  /AddToCartButton.tsx     ← Client Component
  /Cart.tsx                ← Client Component
  /cartStore.ts
/lib
  /products.ts             ← Server-side logic (mock DB)
/type
  /cart.ts

1. /lib/products.ts (Server-Side Logic)

// lib/products.ts
export async function getProducts() {
  return [
    { id: 1, name: "Laptop", price: 1200 },
    { id: 2, name: "Keyboard", price: 100 },
    { id: 3, name: "Mouse", price: 50 },
  ];
}

2. /components/ProductList.tsx (Server Component)

// components/ProductList.tsx
import AddToCartButton from "./AddToCartButton";

import { getProducts } from "@/lib/products";

export default async function ProductList() {
  const products = await getProducts();

  return (
    <div className="bg-white">
      <div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 sm:py-12 lg:max-w-7xl lg:px-8">
        <h2 className="text-2xl font-bold tracking-tight text-gray-900 mb-8">
          Our Products
        </h2>

        <div className="space-y-4">
          {products.map((p) => (
            <div
              key={p.id}
              className="group relative flex items-center rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md transition-shadow duration-200"
            >
              {/* Product Image */}
              <div className="flex-shrink-0 w-16 h-16 overflow-hidden rounded-lg bg-gray-100 mr-4">
                <div className="flex h-full w-full items-center justify-center text-gray-400">
                  <svg
                    className="h-8 w-8"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.75 7.5h16.5-1.5M4.5 7.5l.75-3h13.5l.75 3-1.5 0"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={1.5}
                    />
                  </svg>
                </div>
              </div>

              {/* Product Info */}
              <div className="flex-1 min-w-0">
                <h3 className="text-lg font-semibold text-gray-900 group-hover:text-indigo-600 transition-colors truncate">
                  {p.name}
                </h3>
                <div className="flex items-center mt-1">
                  <p className="text-xl font-bold text-gray-900 mr-3">
                    ${p.price.toFixed(2)}
                  </p>
                  <span className="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
                    In Stock
                  </span>
                </div>
              </div>

              {/* Add to Cart Button */}
              <div className="flex-shrink-0 ml-4">
                <AddToCartButton product={p} />
              </div>

              {/* Hover overlay effect */}
              <div className="absolute inset-0 rounded-lg ring-1 ring-black ring-opacity-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

3. /components/AddToCartButton.tsx (Client Component)

// components/AddToCartButton.tsx
"use client";

import { useCartStore } from "./cartStore";

export default function AddToCartButton({ product }: any) {
  const addToCart = useCartStore((state) => state.addItem);

  return (
    <button
      className="ml-4 inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
      onClick={() => addToCart(product)}
    >
      <svg
        className="mr-2 h-4 w-4"
        fill="none"
        stroke="currentColor"
        viewBox="0 0 24 24"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={2}
          d="M3 3h2l.4 2M7 13h10l4-8H5.4m0 0L7 13m0 0l-1.5 1.5M7 13l1.5 1.5M17 21a2 2 0 100-4 2 2 0 000 4zM9 21a2 2 0 100-4 2 2 0 000 4z"
        />
      </svg>
      Add to Cart
    </button>
  );
}

4. /components/Cart.tsx (Client Component)

// components/Cart.tsx
"use client";

import { CartItem } from "../types/Cart";

import { useCartStore } from "./cartStore";

export default function Cart() {
  const items = useCartStore((state) => state.items);

  return (
    <div>
      <h2>Your Cart</h2>
      {items.length === 0 && <p>No items added.</p>}
      <ul>
        {items.map((item: CartItem) => (
          <li key={item.id}>
            {item.name} - ${item.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

5. /components/cartStore.ts (Client State Management)

// components/cartStore.ts
import { create } from "zustand";

import { CartState, CartItem } from "../types/Cart";

export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  addItem: (item: CartItem) =>
    set((state) => ({
      items: [...state.items, item],
    })),
  removeItem: (id: string | number) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  clearCart: () => set({ items: [] }),
  getTotalPrice: () => get().items.reduce((sum, item) => sum + item.price, 0),
  getTotalItems: () => get().items.length,
}));

6. /app/page.tsx (App Entry Point – Server Component)

// app/page.tsx
import ProductList from "@/components/ProductList";
import Cart from "@/components/Cart";

export default function HomePage() {
  return (
    <main className="min-h-screen bg-gray-50">
      {/* Hero Section */}
      <section className="bg-white border-b border-gray-200">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
          <div className="text-center">
            <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl md:text-6xl">
              <span className="block">Welcome to</span>
              <span className="block text-indigo-600">Our Store</span>
            </h1>
            <p className="mt-3 max-w-md mx-auto text-base text-gray-500 sm:text-lg md:mt-5 md:text-xl md:max-w-3xl">
              Discover amazing products and enjoy a seamless shopping
              experience.
            </p>
          </div>
        </div>
      </section>

      {/* Main Content */}
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
        <div className="lg:grid lg:grid-cols-3 lg:gap-8">
          {/* Product List */}
          <section className="lg:col-span-2">
            <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
              <h2 className="text-2xl font-bold text-gray-900 mb-6">
                Products
              </h2>
              <ProductList />
            </div>
          </section>

          {/* Cart Sidebar */}
          <aside className="mt-8 lg:mt-0">
            <div className="sticky top-8">
              <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
                <h2 className="text-xl font-semibold text-gray-900 mb-4">
                  Shopping Cart
                </h2>
                <Cart />
              </div>
            </div>
          </aside>
        </div>
      </div>
    </main>
  );
}

7. /types/cart.ts

// types/cart.ts
export interface CartItem {
  id: string | number;
  name: string;
  price: number;
  quantity?: number;
}

export interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string | number) => void;
  clearCart: () => void;
  getTotalPrice: () => number;
  getTotalItems: () => number;
}

8. Install zustand

npm i zustand

What This Website Demonstrates

React Server & Client Component Implementation

Ready to Run

  1. Make sure you’ve created a Next.js 13+ project using the App Router.
  2. Replace files as shown above.
  3. Run the project:
npm run dev

Visit http://localhost:3000 and enjoy a fully working demo with both Server and Client Components.

Home page

When you click the add to cart button, the item will be added to the shopping cart section.

Item added to the shopping cart section.

Debugging Tips

Running into issues? Here are a few things to check:

  • Make sure you’re using 'use client' only when needed.
  • Avoid using React hooks within server components.
  • Use console.log() inside the server for debug messages; it will log in your terminal, not the browser.

Finally

Next.js gives you the best of both worlds — powerful server-side rendering and dynamic client-side interactivity. By understanding when and how to use Server and Client Components, you can build faster, more scalable web apps.

Use Server Components for heavy lifting. Keep your interactive logic inside Client Components. Follow the best practices outlined here, and your app will be lean, fast, and developer-friendly.

This article was originally published on Medium.

Leave a Comment

Your email address will not be published. Required fields are marked *