Next Zustand Shopping Cart

Next Zustand Shopping Cart
Authors

Introduction

This project is a web front-end application that includes a shopping cart feature. The application allows users to view products, add them to a cart, and persist the cart items using localStorage.

Fig.1 - Web Demo.

Getting Started Setting up the project

First in your cli

gh repo clone theWhiteFox/web-front-end-developer-test
  • nvm -v0.40.1
  • node -v23.7.0

Then, to run the development server: I am using Bun to run the project locally and build in Vercel.

I optimized the developer experience by pairing the Bun runtime with Next.js Turbopack, resulting in near-instant hot-module reloading (HMR) and significantly faster cold-start times compared to standard Node.js/Webpack setups.

bun i
# or
npm i
# and then
bun dev
# or
npm run dev

Testing & Quality Assurance

For testing, I am using Jest

bun jest
# or
npm run test

Production Readiness

If you are deploying to Vercel or a similar platform, I recommend running a build locally first. While next dev --turbopack is excellent for speed, bun run build preforms a "production-grade" sanity check, catching issues that development mode might overlook:

  • Type Safety: Validates TypeScript across the entire project, not just open files.
  • Next.js 15 Breaking Changes: Ensures all params and searchParams are correctly handled as Promises.
  • Hydration Integrity: Confirms that the Zustand persist middleware doesn't trigger server/client mismatch errors during static generation.
bun run build
# or
npm run build

Successful Build Output:

╭─stephen   󰉖 ~/repos/next-zustand-shopping-cart       ( master)  ?1 ~1 20.20.0
╰─ ❯❯ bun run build
$ next build
   ▲ Next.js 15.1.12

   Creating an optimized production build ...
 ✓ Compiled successfully
 ✓ Linting and checking validity of types
 ✓ Collecting page data
 ✓ Generating static pages (6/6)
 ✓ Collecting build traces
 ✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                    6.9 kB          124 kB
├ ○ /_not-found                          982 B           106 kB
└ ○ /cart                                2.19 kB         119 kB

The final build results in highly optimized bundles, with the main entry point weighing in at only 124 kB, ensuring a fast Time to Interactive (TTI) for users on mobile devices.

Open http://localhost:3000 with your browser to see the result.

You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.

This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.

Features

Display a list of products with their details. Add products to the shopping cart. Persist cart items in localStorage to maintain state across sessions. Load cart items from localStorage when the application mounts.

cart mobile
Fig.2 - Responsive design localStorage.

Server and Client Composition Patterns

app/page.tsx The main page component app/page.tsx is the entry point for the application:

  • Fetches product data from the placeholder data
  • Displays a list of products using the product table component
  • Provides a cart icon in the header with real-time item count
  • Uses the cart store for state management
  • Displays a footer component with links

app/layout.tsx The layout component app/layout.tsx is the parent component for the application:

  • Contains the global CSS and metadata configuration
  • Provides the main layout structure with header and content areas
  • Includes the Toaster component from the react-hot-toast library

app/lib/definitions.ts The definitions file contains TypeScript interfaces and types used throughout the application:

  • Product interface
  • Cart item interface
  • Cart state interface

app/lib/placeholder-data.ts The placeholder data file provides mock product data for the application:

  • Array of products with sample data
  • Each product includes:
    • id: unique identifier
    • name: product name
    • price: numeric price
    • image: image URL
    • inStock: availability status
    • amount: quantity available
product remove
Fig.3 - Remove Product.

app/ui/header.tsx The header component app/ui/header.tsx contains the navigation and cart icon:

  • Displays the site logo/name
  • Shows the cart icon with item count
  • Uses Tailwind CSS for styling
  • Responsive design for mobile and desktop
  • Updates cart count in real-time using Zustand store

The header is present on all pages and provides consistent navigation throughout the application.

app/ui/products/product-card.tsx The product card component app/ui/products/product-card.tsx displays individual product information:

  • Shows product image, name, and price
  • Add to cart button with quantity selection
  • Responsive layout using Tailwind CSS
  • Handles loading and error states
  • Integrates with the cart store for state management

The component is reused across the application to display products consistently. It uses the following Tailwind CSS classes for styling:

  • Card container: rounded shadow with hover effects
  • Image container: fixed aspect ratio and object fit
  • Product details: flex layout with proper spacing
  • Add to cart button: primary color with hover state
  • Price display: prominent typography

The component is built for reusability and maintains consistent styling across the application.

app/ui/products/product-cart.tsx The product cart component app/ui/products/product-cart.tsx displays a list of products in the cart:

  • Shows product image, name, price, and quantity
  • Quantity controls for each item
  • Total price calculation

app/ui/products/product-table.tsx The product table component app/ui/products/product-table.tsx displays a list of products in a table format:

  • Shows product image, name, price, and quantity
  • Quantity controls for each item
  • Total price calculation

Adding Tailwind CSS

In set up I chose tailwind

app/globals.css The global CSS file app/globals.css contains the base styles and Tailwind CSS directives.

Why Zustand? (The Case for Minimalist State)

In a Next.js environment, choosing the right state management is a balance between power and overhead. I choose Zustand over Redux or the native Connect Api for these specific reasons:

  • Zero Boilerplate: Unlike Redux, which requires actions, reducers, and constants, Zustand allowed me to define my state and actions in a single, cohesive store. This kept cartStore file readable and easy to maintain. cartStore.ts

  • Performance (No Unnecessary Re-renders): Context API can be a performance killer because every consumer re-renders when any part of the context changes. Zustand allows components to subscribe to the specific slices of state.

  • Persisting State: Zustand makes complex tasks like Persisting state (saving to localStorage) incredibly simple. I wrapped my store in a persist middleware in about two lines of code-something that requires a significant setup in Redux.

FeatureContext APIRedux ToolkitZustand
BoilerplateLowHighMinimal
Learning CurveEasySteepEasy
PerformancePotential bottlenecksExcellentExcellent
Bundle Size0 (Native)~10kb+~1kb local

Zustand is a lightweight, fast, scalable state management solution. That is designed to be simple and easy to use. It's the choice for state management in this Next application.

State management with LocalStorage

app/store/cartStore.ts The cart store app/store/cartStore.ts manages the state of the shopping cart:

  • Cart items are stored in localStorage
  • Cart state is managed using Zustand
  • Actions include adding items, removing items, and updating quantities
  • State is shared across components
zustand
localStorage
Fig.4 - Zustand localStorage
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { CartItem, CartState, Product } from '../lib/definitions'
import toast from 'react-hot-toast'

const useCartStore = create<CartState>()(
    persist(
        (set, get) => ({
            persist: true,
            items: [],
            addToCart: (product) => {
                let existingProduct: CartItem | undefined
                set((state) => {
                    existingProduct = state.items.find(
                        (item) => item.id === product.id
                    )
                    return {
                        items: existingProduct
                            ? state.items
                            : [
                                  ...state.items,
                                  {
                                      quantity: 1,
                                      id: product.id,
                                      name: product.name,
                                      price: product.price,
                                      image_url: product.image_url,
                                      inStock: product.inStock,
                                      amount: product.amount,
                                  },
                              ],
                    }
                })

                if (existingProduct) {
                    toast.error('Product Already exists')
                } else {
                    toast.success('Product Added successfully')
                }
            },
            remove: (product) => {
                const existingProduct = get().items.find(
                    (item) => item.id === product.id
                )
                if (existingProduct) {
                    set({
                        items: get().items.filter(
                            (item) => item.id !== product.id
                        ),
                    })
                    toast.success('Product removed successfully')
                } else {
                    toast.error('Product not found in cart')
                }
            },
            removeFromCart: (id: number) => {
                set({
                    items: get().items.filter((item) => item.id !== id),
                })
                toast.success('Item removed')
            },
            removeItemCart: (product: Product) => {
                set({
                    items: get().items.filter((item) => item.id !== product.id),
                })
                toast.success('Item removed')
            },
            updateQuantity: (type: 'increment' | 'decrement', id: number) => {
                const { items, removeFromCart } = get()

                const item = items.find((item) => item.id === id)

                if (!item) return

                // 1. Handle removal
                if (type === 'decrement' && item.quantity === 1) {
                    removeFromCart(id)
                    return
                }

                // 2. Handle stock limit

                if (
                    type === 'increment' &&
                    item.amount !== undefined &&
                    item.quantity >= item.amount
                ) {
                    toast.error('Max stock reached')
                    return
                }

                // 3. Update state immutably
                set({
                    items: items.map((i) =>
                        i.id === id
                            ? {
                                  ...i,
                                  quantity:
                                      type === 'increment'
                                          ? i.quantity + 1
                                          : i.quantity - 1,
                              }
                            : i
                    ),
                })
            },
        }),
        {
            name: 'cart-storage', // Uses localStorage by default
        }
    )
)

export default useCartStore

Reference

Photo by Jezael Melgoza on Unsplash