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 is rarely a bug in shadcn/ui itself. The components are copied into your project, and the mismatches come from how they meet the server: a theme class set before hydration, a random skeleton width, dates formatted in another timezone, or a <Button> nested inside a trigger that is already a <button>.

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

On Next.js 14, a shadcn app shows these bugs as "Hydration failed because the initial UI does not match what was rendered on the server." React 19 apps see "Hydration failed because the server rendered HTML didn't match the client" instead. The causes and fixes are the same.

shadcn dark mode hydration error

shadcn/ui's dark mode uses next-themes, which reads the saved theme in an inline script and sets the class on <html> before React hydrates. The server rendered <html> without it, so the attributes differ. shadcn's dark mode guide says to "add the suppressHydrationWarning prop to the html tag":

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 works one level deep: it covers <html> and nothing inside. A component that renders differently for the current theme still breaks. next-themes' README explains why: the theme from useTheme is undefined on the server, so any UI that depends on it must wait until the component has mounted, or render both versions and let CSS (dark: classes) hide one. Theme hydration errors has both fixes.

shadcn sidebar hydration error

Two things in the Sidebar component render differently on the server and in the browser.

SidebarMenuSkeleton picks a random width. The component in shadcn's registry computes its width with Math.random() inside useMemo, and writes it to a --skeleton-width style. The server and the browser pick different numbers, so the style attribute differs. React 19 production builds keep the server's style without reporting it. Because the file is in your project, change it to take the width as a prop:

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 }}
}

The open state. SidebarProvider saves the open state in a sidebar_state cookie. To restore it on reload without a mismatch, read that cookie on the server and pass it as defaultOpen. Reading document.cookie or localStorage during render gives the server and the browser different answers:

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 useIsMobile hook that ships with the Sidebar reads matchMedia in an effect and returns false until then, so it does not cause a mismatch. A version that calls matchMedia during render does; see media query hydration errors.

shadcn calendar hydration error

The Calendar is built on React DayPicker, and dates depend on the timezone. shadcn's Calendar docs pass a timeZone prop and detect it in an effect, because "detecting the timezone during render would cause hydration mismatches, as the server and client may be in different timezones":

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}
    />
  );
}

The same applies to dates you format around the Calendar, in a date picker's button label for example. Format them with an explicit timeZone and locale, or after mount; see timezone and date hydration errors.

shadcn button hydration error

shadcn's triggers, such as TooltipTrigger, DialogTrigger, PopoverTrigger and DropdownMenuTrigger, render a <button> by default. Putting a <Button> inside one without asChild nests a button in a button:

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

The browser's HTML parser closes the first button when it meets the second, so the DOM no longer matches what React rendered. Pass asChild, so the trigger passes its behavior to your <Button> instead of rendering its own:

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 that looks like a button, shadcn's Radix-based Button documents <Button asChild> around the link; the Base UI version recommends buttonVariants() on a plain <a>. Wrapping a <Button> in a <Link> instead puts a button inside a link, which is invalid HTML. See invalid HTML nesting.

Find every one in your app

The ESLint rule no-invalid-interactive-nesting reports nesting it can see in one file, such as a <button> written inside an <a>. A <Button> inside <TooltipTrigger> crosses two components, so no linter sees it. no-random-in-render reports the Math.random() in the skeleton, because useMemo callbacks run during render.

hydration-proof test loads each page in a browser and finds all four, including the nesting across components (reported as HP3002). Test dark mode and a phone viewport too, because the theme and the sidebar only differ there:

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