# Fix shadcn/ui hydration errors

> A shadcn hydration error usually comes from dark mode, the Sidebar skeleton, dates in the Calendar, or a Button nested in a trigger. The fix for each one.

Source: https://hydration.jscrate.dev/docs/guides/shadcn-hydration-error
Last updated: 2026-09-18

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 see                                                               | Section                                        |
| -------------------------------------------------------------------------- | ---------------------------------------------- |
| A `class` or `style` difference on `<html>`                                | [Dark mode](#shadcn-dark-mode-hydration-error) |
| A `--skeleton-width` style difference, or a sidebar that opens differently | [Sidebar](#shadcn-sidebar-hydration-error)     |
| A date or day that differs by one                                          | [Calendar](#shadcn-calendar-hydration-error)   |
| "In HTML, `<button>` cannot be a descendant of `<button>`"                 | [Button](#shadcn-button-hydration-error)       |

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](https://ui.shadcn.com/docs/dark-mode/next) says to "add the `suppressHydrationWarning` prop to the `html` tag":

```tsx title="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](https://hydration.jscrate.dev/docs/guides/suppresshydrationwarning): 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](https://hydration.jscrate.dev/docs/causes/theme) 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:

```tsx title="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:

```tsx title="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](https://hydration.jscrate.dev/docs/causes/media-query).

## shadcn calendar hydration error

The Calendar is built on React DayPicker, and dates depend on the timezone. shadcn's [Calendar docs](https://ui.shadcn.com/docs/components/calendar) 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":

```tsx title="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](https://hydration.jscrate.dev/docs/causes/timezone) and [date](https://hydration.jscrate.dev/docs/causes/time) 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:

```text
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:

```tsx title="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](https://hydration.jscrate.dev/docs/causes/invalid-html).

## Find every one in your app

The ESLint rule [`no-invalid-interactive-nesting`](https://hydration.jscrate.dev/docs/rules/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`](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/issues/hp3002)). Test dark mode and a phone viewport too, because the theme and the sidebar only differ there:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  scenarios: [
    { name: "default" },
    { name: "dark-mobile", colorScheme: "dark", viewport: "mobile" },
  ],
});
```

```bash
npx hydration-proof test
```

## Related

- [Next-themes and dark mode hydration errors](https://hydration.jscrate.dev/docs/causes/theme)
- [validateDOMNesting errors](https://hydration.jscrate.dev/docs/errors/validatedomnesting)
- [suppressHydrationWarning, and why it works one level deep](https://hydration.jscrate.dev/docs/guides/suppresshydrationwarning)
- [Next.js hydration errors](https://hydration.jscrate.dev/docs/frameworks/nextjs)
- [Random values in render](https://hydration.jscrate.dev/docs/causes/random)
