Zustand + Next.js App Router: Modern React State Management from Zero to Production

Noqta TeamAI Bot
By Noqta Team & AI Bot ·

Loading the Text to Speech Audio Player...

Lightweight state management that just works. Zustand is the minimalist, hook-based state manager that has become the go-to choice for React developers in 2026. In this tutorial, you will build a real-world shopping cart application with Next.js 15 App Router, learning every Zustand pattern you need for production.

What You Will Learn

By the end of this tutorial, you will:

  • Set up Zustand in a Next.js 15 App Router project with TypeScript
  • Create type-safe stores with actions, selectors, and computed values
  • Use middleware for logging, persistence, and devtools integration
  • Handle server-side rendering and hydration with Zustand stores
  • Implement store slices to organize complex state
  • Build persistent state with localStorage and custom storage engines
  • Apply real-world patterns for a production shopping cart

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, generics, hooks)
  • Familiarity with Next.js App Router basics (layouts, pages, server/client components)
  • Basic understanding of React state (useState, useContext)

Why Zustand in 2026?

React state management has evolved dramatically. While Redux dominated for years, the ecosystem has shifted toward simpler, more ergonomic solutions. Here is why Zustand stands out:

FeatureZustandRedux ToolkitJotaiContext API
Bundle size~1 KB~12 KB~3 KB0 KB (built-in)
BoilerplateMinimalModerateMinimalHigh at scale
TypeScript DXExcellentGoodGoodManual
SSR supportBuilt-inExtra setupBuilt-inBuilt-in
DevtoolsVia middlewareBuilt-inExtensionNone
Learning curveLowModerateLowLow (scales poorly)

Zustand gives you the simplicity of useState with the power of a global store, no providers required.


Step 1: Project Setup

Create a new Next.js 15 project with TypeScript:

npx create-next-app@latest zustand-cart --typescript --tailwind --app --src-dir
cd zustand-cart

Install Zustand:

npm install zustand

Your project structure should look like this:

zustand-cart/
├── src/
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── cart/
│   │       └── page.tsx
│   ├── components/
│   ├── stores/
│   └── types/
├── package.json
└── tsconfig.json

Create the directories we will need:

mkdir -p src/stores src/types src/components

Step 2: Define Your Types

Start with clean type definitions. Create src/types/cart.ts:

// src/types/cart.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  category: string;
}
 
export interface CartItem {
  product: Product;
  quantity: number;
}
 
export interface CartSummary {
  totalItems: number;
  totalPrice: number;
  discount: number;
  finalPrice: number;
}

Step 3: Create Your First Zustand Store

This is where Zustand shines. Create src/stores/cart-store.ts:

// src/stores/cart-store.ts
import { create } from "zustand";
import type { Product, CartItem } from "@/types/cart";
 
interface CartState {
  // State
  items: CartItem[];
  isOpen: boolean;
 
  // Actions
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;
 
  // Computed (derived state)
  getTotalItems: () => number;
  getTotalPrice: () => number;
}
 
export const useCartStore = create<CartState>((set, get) => ({
  // Initial state
  items: [],
  isOpen: false,
 
  // Actions
  addItem: (product) =>
    set((state) => {
      const existingItem = state.items.find(
        (item) => item.product.id === product.id
      );
 
      if (existingItem) {
        return {
          items: state.items.map((item) =>
            item.product.id === product.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          ),
        };
      }
 
      return { items: [...state.items, { product, quantity: 1 }] };
    }),
 
  removeItem: (productId) =>
    set((state) => ({
      items: state.items.filter((item) => item.product.id !== productId),
    })),
 
  updateQuantity: (productId, quantity) =>
    set((state) => ({
      items:
        quantity <= 0
          ? state.items.filter((item) => item.product.id !== productId)
          : state.items.map((item) =>
              item.product.id === productId ? { ...item, quantity } : item
            ),
    })),
 
  clearCart: () => set({ items: [] }),
 
  toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
 
  // Computed values using get()
  getTotalItems: () =>
    get().items.reduce((total, item) => total + item.quantity, 0),
 
  getTotalPrice: () =>
    get().items.reduce(
      (total, item) => total + item.product.price * item.quantity,
      0
    ),
}));

Notice how clean this is compared to Redux. No action types, no reducers, no dispatch. Just a function that returns state and actions.


Step 4: Use the Store in Components

Product Card Component

Create src/components/product-card.tsx:

// src/components/product-card.tsx
"use client";
 
import { useCartStore } from "@/stores/cart-store";
import type { Product } from "@/types/cart";
 
interface ProductCardProps {
  product: Product;
}
 
export function ProductCard({ product }: ProductCardProps) {
  const addItem = useCartStore((state) => state.addItem);
 
  return (
    <div className="rounded-lg border p-4 shadow-sm hover:shadow-md transition-shadow">
      <div className="aspect-square bg-gray-100 rounded-md mb-3 flex items-center justify-center">
        <span className="text-4xl">{product.image}</span>
      </div>
      <h3 className="font-semibold text-lg">{product.name}</h3>
      <p className="text-gray-500 text-sm">{product.category}</p>
      <div className="flex items-center justify-between mt-3">
        <span className="text-xl font-bold">${product.price.toFixed(2)}</span>
        <button
          onClick={() => addItem(product)}
          className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
        >
          Add to Cart
        </button>
      </div>
    </div>
  );
}

Cart Badge Component

Create src/components/cart-badge.tsx:

// src/components/cart-badge.tsx
"use client";
 
import { useCartStore } from "@/stores/cart-store";
 
export function CartBadge() {
  // Subscribe to only what you need - this is key for performance
  const totalItems = useCartStore((state) => state.getTotalItems());
  const toggleCart = useCartStore((state) => state.toggleCart);
 
  return (
    <button
      onClick={toggleCart}
      className="relative p-2 rounded-full hover:bg-gray-100"
    >
      <svg
        className="w-6 h-6"
        fill="none"
        stroke="currentColor"
        viewBox="0 0 24 24"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={2}
          d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"
        />
      </svg>
      {totalItems > 0 && (
        <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
          {totalItems}
        </span>
      )}
    </button>
  );
}

Key insight: Notice how we use (state) => state.getTotalItems() as a selector. Zustand only re-renders a component when the selected value changes. This is automatic performance optimization.


Step 5: Selectors and Performance

Zustand re-renders components only when the selected state changes. This is critical for performance. Here are the patterns:

Bad: Selecting the entire store

// This re-renders on ANY state change
const store = useCartStore();

Good: Selecting specific values

// Only re-renders when items change
const items = useCartStore((state) => state.items);
 
// Only re-renders when isOpen changes
const isOpen = useCartStore((state) => state.isOpen);

Advanced: Custom equality functions

For derived values that return new object references, use shallow comparison:

import { useShallow } from "zustand/react/shallow";
 
// Only re-renders when the actual values change, not reference
const { items, isOpen } = useCartStore(
  useShallow((state) => ({
    items: state.items,
    isOpen: state.isOpen,
  }))
);

Creating Reusable Selectors

Create src/stores/cart-selectors.ts:

// src/stores/cart-selectors.ts
import type { CartState } from "@/stores/cart-store";
 
export const selectCartItems = (state: CartState) => state.items;
export const selectIsCartOpen = (state: CartState) => state.isOpen;
export const selectCartTotal = (state: CartState) => state.getTotalPrice();
export const selectCartCount = (state: CartState) => state.getTotalItems();
 
export const selectItemById = (productId: string) => (state: CartState) =>
  state.items.find((item) => item.product.id === productId);

Usage:

const items = useCartStore(selectCartItems);
const total = useCartStore(selectCartTotal);
const item = useCartStore(selectItemById("product-1"));

Step 6: Middleware - Persistence

One of Zustand's most powerful features is its middleware system. Let us add persistence to save the cart in localStorage.

Update src/stores/cart-store.ts:

// src/stores/cart-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { Product, CartItem } from "@/types/cart";
 
interface CartState {
  items: CartItem[];
  isOpen: boolean;
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;
  getTotalItems: () => number;
  getTotalPrice: () => number;
}
 
export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      isOpen: false,
 
      addItem: (product) =>
        set((state) => {
          const existingItem = state.items.find(
            (item) => item.product.id === product.id
          );
          if (existingItem) {
            return {
              items: state.items.map((item) =>
                item.product.id === product.id
                  ? { ...item, quantity: item.quantity + 1 }
                  : item
              ),
            };
          }
          return { items: [...state.items, { product, quantity: 1 }] };
        }),
 
      removeItem: (productId) =>
        set((state) => ({
          items: state.items.filter((item) => item.product.id !== productId),
        })),
 
      updateQuantity: (productId, quantity) =>
        set((state) => ({
          items:
            quantity <= 0
              ? state.items.filter((item) => item.product.id !== productId)
              : state.items.map((item) =>
                  item.product.id === productId ? { ...item, quantity } : item
                ),
        })),
 
      clearCart: () => set({ items: [] }),
      toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
      getTotalItems: () =>
        get().items.reduce((total, item) => total + item.quantity, 0),
      getTotalPrice: () =>
        get().items.reduce(
          (total, item) => total + item.product.price * item.quantity,
          0
        ),
    }),
    {
      name: "cart-storage",
      storage: createJSONStorage(() => localStorage),
      // Only persist items, not UI state like isOpen
      partialize: (state) => ({ items: state.items }),
    }
  )
);

The partialize option is important: you should only persist data state, not UI state like whether the cart drawer is open.


Step 7: Handling SSR and Hydration

Next.js App Router renders components on the server first. Since localStorage does not exist on the server, we need to handle hydration carefully.

Create src/stores/hydration.ts:

// src/stores/hydration.ts
"use client";
 
import { useEffect, useState } from "react";
 
export function useHydration() {
  const [hydrated, setHydrated] = useState(false);
 
  useEffect(() => {
    setHydrated(true);
  }, []);
 
  return hydrated;
}

Create a hydration-safe cart component at src/components/cart-sidebar.tsx:

// src/components/cart-sidebar.tsx
"use client";
 
import { useCartStore } from "@/stores/cart-store";
import { useHydration } from "@/stores/hydration";
 
export function CartSidebar() {
  const hydrated = useHydration();
  const items = useCartStore((state) => state.items);
  const isOpen = useCartStore((state) => state.isOpen);
  const toggleCart = useCartStore((state) => state.toggleCart);
  const removeItem = useCartStore((state) => state.removeItem);
  const updateQuantity = useCartStore((state) => state.updateQuantity);
  const clearCart = useCartStore((state) => state.clearCart);
  const getTotalPrice = useCartStore((state) => state.getTotalPrice);
 
  if (!isOpen) return null;
 
  return (
    <div className="fixed inset-0 z-50 flex justify-end">
      {/* Backdrop */}
      <div className="absolute inset-0 bg-black/50" onClick={toggleCart} />
 
      {/* Sidebar */}
      <div className="relative w-full max-w-md bg-white shadow-xl p-6 overflow-y-auto">
        <div className="flex justify-between items-center mb-6">
          <h2 className="text-2xl font-bold">Your Cart</h2>
          <button onClick={toggleCart} className="text-gray-500 hover:text-gray-700">

          </button>
        </div>
 
        {!hydrated ? (
          <div className="animate-pulse space-y-4">
            {[1, 2, 3].map((i) => (
              <div key={i} className="h-20 bg-gray-200 rounded" />
            ))}
          </div>
        ) : items.length === 0 ? (
          <p className="text-gray-500 text-center py-8">Your cart is empty</p>
        ) : (
          <>
            <div className="space-y-4">
              {items.map((item) => (
                <div
                  key={item.product.id}
                  className="flex items-center gap-4 border-b pb-4"
                >
                  <span className="text-3xl">{item.product.image}</span>
                  <div className="flex-1">
                    <h3 className="font-medium">{item.product.name}</h3>
                    <p className="text-gray-500">
                      ${item.product.price.toFixed(2)}
                    </p>
                  </div>
                  <div className="flex items-center gap-2">
                    <button
                      onClick={() =>
                        updateQuantity(item.product.id, item.quantity - 1)
                      }
                      className="w-8 h-8 rounded-full border flex items-center justify-center"
                    >
                      -
                    </button>
                    <span className="w-8 text-center">{item.quantity}</span>
                    <button
                      onClick={() =>
                        updateQuantity(item.product.id, item.quantity + 1)
                      }
                      className="w-8 h-8 rounded-full border flex items-center justify-center"
                    >
                      +
                    </button>
                  </div>
                  <button
                    onClick={() => removeItem(item.product.id)}
                    className="text-red-500 hover:text-red-700"
                  >

                  </button>
                </div>
              ))}
            </div>
 
            <div className="mt-6 border-t pt-4">
              <div className="flex justify-between text-xl font-bold">
                <span>Total:</span>
                <span>${getTotalPrice().toFixed(2)}</span>
              </div>
              <button className="w-full mt-4 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition-colors">
                Checkout
              </button>
              <button
                onClick={clearCart}
                className="w-full mt-2 text-gray-500 hover:text-gray-700"
              >
                Clear cart
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

Step 8: Store Slices for Complex Apps

As your app grows, you will want to split your store into slices. This keeps each piece focused and testable.

Create src/stores/slices/cart-slice.ts:

// src/stores/slices/cart-slice.ts
import type { StateCreator } from "zustand";
import type { Product, CartItem } from "@/types/cart";
 
export interface CartSlice {
  items: CartItem[];
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  clearCart: () => void;
  getTotalItems: () => number;
  getTotalPrice: () => number;
}
 
export const createCartSlice: StateCreator<CartSlice> = (set, get) => ({
  items: [],
 
  addItem: (product) =>
    set((state) => {
      const existing = state.items.find((i) => i.product.id === product.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.product.id === product.id
              ? { ...i, quantity: i.quantity + 1 }
              : i
          ),
        };
      }
      return { items: [...state.items, { product, quantity: 1 }] };
    }),
 
  removeItem: (productId) =>
    set((state) => ({
      items: state.items.filter((i) => i.product.id !== productId),
    })),
 
  clearCart: () => set({ items: [] }),
 
  getTotalItems: () =>
    get().items.reduce((sum, i) => sum + i.quantity, 0),
 
  getTotalPrice: () =>
    get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
});

Create src/stores/slices/ui-slice.ts:

// src/stores/slices/ui-slice.ts
import type { StateCreator } from "zustand";
 
export interface UISlice {
  isCartOpen: boolean;
  isSearchOpen: boolean;
  theme: "light" | "dark";
  toggleCart: () => void;
  toggleSearch: () => void;
  setTheme: (theme: "light" | "dark") => void;
}
 
export const createUISlice: StateCreator<UISlice> = (set) => ({
  isCartOpen: false,
  isSearchOpen: false,
  theme: "light",
 
  toggleCart: () => set((state) => ({ isCartOpen: !state.isCartOpen })),
  toggleSearch: () => set((state) => ({ isSearchOpen: !state.isSearchOpen })),
  setTheme: (theme) => set({ theme }),
});

Combine slices in src/stores/app-store.ts:

// src/stores/app-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { createCartSlice, type CartSlice } from "./slices/cart-slice";
import { createUISlice, type UISlice } from "./slices/ui-slice";
 
type AppStore = CartSlice & UISlice;
 
export const useAppStore = create<AppStore>()(
  persist(
    (...args) => ({
      ...createCartSlice(...args),
      ...createUISlice(...args),
    }),
    {
      name: "app-storage",
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ items: state.items, theme: state.theme }),
    }
  )
);

Now you have a clean, modular store that scales with your application.


Step 9: Devtools and Logging Middleware

Stack multiple middleware for a great development experience:

// src/stores/app-store.ts (enhanced version)
import { create } from "zustand";
import { persist, createJSONStorage, devtools } from "zustand/middleware";
import { createCartSlice, type CartSlice } from "./slices/cart-slice";
import { createUISlice, type UISlice } from "./slices/ui-slice";
 
type AppStore = CartSlice & UISlice;
 
export const useAppStore = create<AppStore>()(
  devtools(
    persist(
      (...args) => ({
        ...createCartSlice(...args),
        ...createUISlice(...args),
      }),
      {
        name: "app-storage",
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({ items: state.items, theme: state.theme }),
      }
    ),
    { name: "AppStore" }
  )
);

Install the Redux DevTools browser extension to inspect your Zustand store in real time. Every state change will appear with action names and state diffs.

Custom Logging Middleware

// src/stores/middleware/logger.ts
import type { StateCreator, StoreMutatorIdentifier } from "zustand";
 
type Logger = <
  T,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
  f: StateCreator<T, Mps, Mcs>,
  name?: string
) => StateCreator<T, Mps, Mcs>;
 
type LoggerImpl = <T>(
  f: StateCreator<T, [], []>,
  name?: string
) => StateCreator<T, [], []>;
 
const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
  const loggedSet: typeof set = (...args) => {
    const prev = get();
    set(...(args as Parameters<typeof set>));
    const next = get();
    if (process.env.NODE_ENV === "development") {
      console.log(`[${name ?? "store"}]`, { prev, next });
    }
  };
  return f(loggedSet, get, store);
};
 
export const logger = loggerImpl as unknown as Logger;

Step 10: Putting It All Together

Create the main page at src/app/page.tsx:

// src/app/page.tsx
import { ProductCard } from "@/components/product-card";
import { CartBadge } from "@/components/cart-badge";
import { CartSidebar } from "@/components/cart-sidebar";
import type { Product } from "@/types/cart";
 
const products: Product[] = [
  { id: "1", name: "Wireless Headphones", price: 79.99, image: "🎧", category: "Electronics" },
  { id: "2", name: "Mechanical Keyboard", price: 149.99, image: "⌨️", category: "Electronics" },
  { id: "3", name: "Running Shoes", price: 129.99, image: "👟", category: "Sports" },
  { id: "4", name: "Coffee Maker", price: 89.99, image: "☕", category: "Kitchen" },
  { id: "5", name: "Desk Lamp", price: 39.99, image: "💡", category: "Office" },
  { id: "6", name: "Backpack", price: 59.99, image: "🎒", category: "Accessories" },
];
 
export default function HomePage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow-sm sticky top-0 z-40">
        <div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
          <h1 className="text-2xl font-bold">Zustand Store</h1>
          <CartBadge />
        </div>
      </header>
 
      <main className="max-w-7xl mx-auto px-4 py-8">
        <h2 className="text-3xl font-bold mb-8">Products</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {products.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))}
        </div>
      </main>
 
      <CartSidebar />
    </div>
  );
}

Step 11: Testing Zustand Stores

Zustand stores are plain functions, making them easy to test:

// src/stores/__tests__/cart-store.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { useCartStore } from "../cart-store";
 
const mockProduct = {
  id: "test-1",
  name: "Test Product",
  price: 29.99,
  image: "🧪",
  category: "Test",
};
 
describe("Cart Store", () => {
  beforeEach(() => {
    // Reset store between tests
    useCartStore.setState({ items: [] });
  });
 
  it("adds an item to the cart", () => {
    useCartStore.getState().addItem(mockProduct);
    const items = useCartStore.getState().items;
 
    expect(items).toHaveLength(1);
    expect(items[0].product.id).toBe("test-1");
    expect(items[0].quantity).toBe(1);
  });
 
  it("increments quantity for existing items", () => {
    useCartStore.getState().addItem(mockProduct);
    useCartStore.getState().addItem(mockProduct);
 
    const items = useCartStore.getState().items;
    expect(items).toHaveLength(1);
    expect(items[0].quantity).toBe(2);
  });
 
  it("removes an item from the cart", () => {
    useCartStore.getState().addItem(mockProduct);
    useCartStore.getState().removeItem("test-1");
 
    expect(useCartStore.getState().items).toHaveLength(0);
  });
 
  it("calculates total price correctly", () => {
    useCartStore.getState().addItem(mockProduct);
    useCartStore.getState().addItem(mockProduct);
 
    expect(useCartStore.getState().getTotalPrice()).toBe(59.98);
  });
 
  it("clears the entire cart", () => {
    useCartStore.getState().addItem(mockProduct);
    useCartStore.getState().clearCart();
 
    expect(useCartStore.getState().items).toHaveLength(0);
  });
});

Notice how you access the store directly with getState() and setState() outside of React components. No need for render utilities or providers.


Troubleshooting

Hydration mismatch warnings

If you see hydration mismatches with persistent stores, ensure client components using persistent state show a loading skeleton until hydrated:

const hydrated = useHydration();
if (!hydrated) return <Skeleton />;

Store not updating across tabs

Add the storage event listener for cross-tab synchronization:

persist(
  // ... store config
  {
    name: "cart-storage",
    storage: createJSONStorage(() => localStorage),
    // This enables cross-tab sync
    skipHydration: false,
  }
)

Performance issues with large stores

Use granular selectors instead of selecting the entire store. Use useShallow when selecting multiple values that return new object references.


Next Steps

Now that you have mastered Zustand with Next.js App Router, here are some ways to extend this further:

  • Add Immer middleware for more ergonomic nested state updates
  • Build a wishlist using a separate store slice
  • Integrate with a real API using Zustand's async actions
  • Add optimistic updates for server mutations
  • Explore Zustand's subscribeWithSelector for side effects outside React

Conclusion

Zustand offers the perfect balance between simplicity and power for React state management in 2026. Its minimal API, excellent TypeScript support, and seamless integration with Next.js App Router make it the ideal choice for modern applications.

You have learned how to create stores, use selectors for performance, add persistence, handle SSR hydration, organize code with slices, and test everything. These patterns scale from simple counter apps to complex e-commerce platforms.

The key takeaway: start simple with a single store, add middleware as needed, and split into slices only when complexity demands it. Zustand grows with your application without ever getting in your way.


Want to read more tutorials? Check out our latest tutorial on Introduction to Vibe Coding: AI-Assisted Development for Modern Teams.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide

Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.

25 min read·

Building a Content-Driven Website with Payload CMS 3 and Next.js

Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.

30 min read·