Hydration Proof

Search documentation

Find a page or section

Fix shadcn/ui hydration errors

Dark mode, Sidebar, Calendar and Button: four causes, four fixes.

A shadcn hydration error usually comes from how a component is used, not from shadcn/ui itself. Common causes are a theme class added before hydration, a random Sidebar skeleton width, a date shown in different timezones, or nested buttons. Find the symptom below, then apply its fix.

Which shadcn hydration error do you have?

What you seeSection
A class or style difference on <html>Dark mode
A --skeleton-width style difference, or a sidebar that opens differentlySidebar
A date or day that differs by oneCalendar
"In HTML, <button> cannot be a descendant of <button>"Button

In a Next.js 14 app using shadcn/ui, React may say "Hydration failed because the initial UI does not match what was rendered on the server." Newer React versions use different wording. In either case, check what changed between server HTML and the first browser render.

shadcn dark mode hydration error

With next-themes, a script can set the theme class on <html> before React hydrates. The server did not include that class, so the attributes differ. The shadcn/ui dark mode guide puts suppressHydrationWarning on <html>:

app/layout.tsx
import type { ReactNode } from "react";
import { ThemeProvider } from "@/components/theme-provider";
 
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

The prop covers only <html> itself. It will not fix a theme toggle whose icon changes between renders. For that, wait until mount or render both icons and use CSS to show the right one. See theme hydration errors.

shadcn sidebar hydration error

Check both the skeleton width and the saved open state.

Skeleton width. Some SidebarMenuSkeleton versions use Math.random() to choose --skeleton-width. The server and browser may choose different values. Because the component is in your project, give it a stable width instead:

components/ui/sidebar.tsx
function SidebarMenuSkeleton({
  className,
  showIcon = false,
  width = "70%", // was: a random width between 50% and 90%
  ...props
}: React.ComponentProps<"div"> & {
  showIcon?: boolean;
  width?: string;
}) {
  // ...render as before, with style={{ "--skeleton-width": width }}
}

Open state. SidebarProvider can save its state in a sidebar_state cookie. Read that cookie on the server and pass its value as defaultOpen. Reading browser storage during render would make the first render disagree:

app/(dashboard)/layout.tsx
import type { ReactNode } from "react";
import { cookies } from "next/headers";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";
 
export default async function DashboardLayout({
  children,
}: {
  children: ReactNode;
}) {
  const cookieStore = await cookies();
  const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false";
 
  return (
    <SidebarProvider defaultOpen={defaultOpen}>
      <AppSidebar />
      <main>{children}</main>
    </SidebarProvider>
  );
}

The shipped useIsMobile hook reads matchMedia after mount. If your version reads it during render, see media query hydration errors.

shadcn calendar hydration error

Calendar dates can differ when the server and browser use different timezones. The shadcn/ui Calendar docs show how to detect the browser's timezone after mount:

components/calendar-with-timezone.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Calendar } from "@/components/ui/calendar";
 
export function CalendarWithTimezone() {
  const [date, setDate] = useState<Date | undefined>(undefined);
  const [timeZone, setTimeZone] = useState<string | undefined>(undefined);
 
  useEffect(() => {
    setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
  }, []);
 
  return (
    <Calendar
      mode="single"
      selected={date}
      onSelect={setDate}
      timeZone={timeZone}
    />
  );
}

Check dates outside the Calendar too, such as the label on a date picker. Use an explicit locale and timezone, or format after mount. See timezone and date.

shadcn button hydration error

Triggers such as TooltipTrigger and DialogTrigger can render a <button> themselves. Putting a <Button> inside one may create nested buttons:

In HTML, <button> cannot be a descendant of <button>. This will cause a hydration error.

The browser changes this invalid HTML before React hydrates. On Radix-based triggers, use asChild so the trigger uses your <Button> instead of adding another button:

components/save-button.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
 
export function SaveButton() {
  return (
    <Tooltip>
      {/* Without asChild: <button><button>Save</button></button> */}
      <TooltipTrigger asChild>
        <Button>Save</Button>
      </TooltipTrigger>
      <TooltipContent>Save the draft</TooltipContent>
    </Tooltip>
  );
}

For a link styled as a button, use the pattern for your installed shadcn/ui component version. Avoid nesting interactive elements. See invalid HTML nesting.

Find every one in your app

no-invalid-interactive-nesting catches nesting written in one file. It may not see markup produced across components. no-random-in-render catches a random skeleton width, including one calculated in useMemo.

hydration-proof test checks the rendered page and can find nesting across components (HP3002). Add a dark, mobile scenario to cover theme and sidebar differences:

hydration-proof.config.ts
import { defineConfig } from "hydration-proof";
 
export default defineConfig({
  scenarios: [
    { name: "default" },
    { name: "dark-mobile", colorScheme: "dark", viewport: "mobile" },
  ],
});
npx hydration-proof test