Fix Next.js Hydration Errors Caused by Client-Only State (Cart Badge Case)

Fix Next.js Hydration Errors Caused by Client-Only State (Cart Badge Case)


Next.js Hydration React State Client State

Hydration errors in Next.js are annoying, noisy, and always a signal of a real bug—not something you should silence.

One of the most common cases is a cart badge that depends on client-side state.

Let’s break it down and fix it properly.


The Problem: Server and Client Render Different UI

You have code like this:

{totalItems > 0 && (
  <Badge variant="destructive">{totalItems}</Badge>
)}

At first glance, this looks fine. It’s not.

What Actually Happens

  • Server render (SSR)

    • totalItems is 0 or undefined

    • No <Badge /> is rendered

  • Client hydration

    • totalItems becomes 3 (from Zustand, Redux, localStorage, etc.)

    • <Badge>3</Badge> suddenly appears

💥 Boom — hydration mismatch

React sees:

  • Server HTML: no badge

  • Client HTML: badge exists

React throws a hydration error because the DOM doesn’t match.


Root Cause (Be Honest About It)

The root cause is simple:

You are rendering client-only data during SSR

Common sources:

  • Zustand / Redux store hydrated from localStorage

  • Context initialized in useEffect

  • Browser-only APIs

  • Client-only API calls

🚫 The server cannot access any of these.


✅ Option 1: Use a Mounted Guard (Best Practice)

Only render client-dependent UI after the component mounts.

"use client";

import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { ShoppingCart } from "lucide-react";

export function CartIcon({ totalItems }: { totalItems: number }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  return (
    <div className="relative">
      <ShoppingCart className="h-6 w-6" />

      {mounted && totalItems > 0 && (
        <Badge
          variant="destructive"
          className="absolute -top-2 -right-2 h-5 w-5 flex items-center justify-center p-0 text-xs"
        >
          {totalItems}
        </Badge>
      )}
    </div>
  );
}

Why This Works

PhaseWhat Renders
ServerCart icon only
Client (first render)Cart icon only
After mountCart icon + badge

✅ Server and client HTML match
✅ No hydration mismatch
✅ Clean mental model

This pattern is mandatory when using client-only state.


Alternative (Sometimes Useful, Not Enough Alone)

✅ Option 2: Initialize State Consistently

If you’re using Zustand or Redux, never let values be undefined.

// store/cartStore.ts
const useCartStore = create(() => ({
  totalItems: 0, // always predictable
}));

⚠️ Important:
This does NOT fully solve the problem if:

  • Real data comes from localStorage

  • Data is fetched only on the client

You’ll still need the mounted guard.


What NOT To Do (Seriously, Don’t)

🚫 suppressHydrationWarning

<div suppressHydrationWarning>
  {totalItems > 0 && <Badge>{totalItems}</Badge>}
</div>

This:

  • Hides the error

  • Keeps the bug

  • Makes future bugs harder to detect

Use this only as a last resort for legacy code.


Bonus: Clean Up Your JSX

This class is repeated and noisy:

className="absolute -top-2 -right-2 h-5 w-5 flex items-center justify-center p-0 text-xs"

Move it into:

  • A reusable component

  • A variant

  • Or a utility class

Cleaner JSX = fewer bugs.


Mental Rule to Remember

If data comes from the browser, don’t render it on the server.

Use:

  • mounted guard

  • dynamic(() => import(...), { ssr: false })

  • Or server-provided data

But never mix SSR markup with client-only values.


Final Verdict

  • ✅ Hydration errors are real bugs

  • ✅ Mounted guards are the safest fix

  • 🚫 Don’t silence warnings

  • 🚫 Don’t trust client state during SSR

If your totalItems comes from Zustand, Redux, or Context, say the word—I’ll give you a store-level hydration pattern that scales cleanly.

Thank you for stopping by

© 2026 - Oktaviardi.com