# shadcn Sidebar Icon Rail in Next.js: Keep the Collapsed State With a Cookie

> shadcn's Sidebar writes a sidebar_state cookie on every toggle but never reads it. Read it with cookies() in a route group layout and pass defaultOpen, treating a missing cookie as open. On a new Next.js 16 app, Cache Components is on, so move the cookie read into a child component wrapped in Suspense or the build fails.

- URL: https://qodesign.dev/blog/shadcn-sidebar-collapsible-icon-cookie-nextjs
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

shadcn's Sidebar already saves its open or collapsed state in a cookie called `sidebar_state`. What it doesn't do is read that cookie back. That part is yours: read it on the server in the layout that renders `SidebarProvider` and pass it in as `defaultOpen`. Do that and a collapsed icon rail stays collapsed after a reload, with no flash of the full sidebar. On a fresh Next.js 16 app there's one more step, because Cache Components is switched on and reading cookies in a layout breaks the build until you add a Suspense boundary.

I build app-shell pieces for qodesign, including a [collapsible sidebar that reflows the page](/components/reflow-sidebar), so I set this up from scratch to see what actually happens. The test app was `create-next-app` 16.4.2 with React 19.3, Tailwind CSS 4.3 and `shadcn add sidebar` (shadcn 4.22, which installs the Base UI version of the component). Every file below went through strict `tsc`, `next build` and `next start`, then headless Chromium clicked through it at 1280 px and 390 px.

## The setup that works

The cookie is read in a route group layout, `app/(app)/layout.tsx`, so only the pages that have the sidebar pay for it:

```tsx
// app/(app)/layout.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    // cookies() is request data, so it has to sit inside a Suspense boundary
    // when Cache Components is on. The fallback is the static part of the page.
    <Suspense fallback={<div className="min-h-svh" data-shell-fallback />}>
      <SidebarShell>{children}</SidebarShell>
    </Suspense>
  );
}

async function SidebarShell({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  // No cookie yet (first visit) means open. Only an explicit "false" collapses it.
  const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false";

  return (
    <SidebarProvider defaultOpen={defaultOpen}>
      <AppSidebar />
      <SidebarInset>
        <header className="flex h-12 items-center gap-2 border-b px-3">
          <SidebarTrigger />
          <span className="text-sm text-muted-foreground">Ctrl/Cmd + B</span>
        </header>
        <div className="p-6">{children}</div>
      </SidebarInset>
    </SidebarProvider>
  );
}
```

The sidebar itself is a client component, because it needs `usePathname` to mark the current page:

```tsx
// components/app-sidebar.tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { BarChart3, Home, Settings, Users } from "lucide-react";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarRail,
} from "@/components/ui/sidebar";

const items = [
  { title: "Overview", href: "/dashboard", icon: Home },
  { title: "Revenue", href: "/dashboard/revenue", icon: BarChart3 },
  { title: "Customers", href: "/dashboard/customers", icon: Users },
  { title: "Settings", href: "/dashboard/settings", icon: Settings },
];

export function AppSidebar() {
  const pathname = usePathname();

  return (
    <Sidebar collapsible="icon">
      <SidebarHeader>
        <span className="truncate px-2 text-sm font-semibold group-data-[collapsible=icon]:hidden">
          Northwind
        </span>
      </SidebarHeader>
      <SidebarContent>
        <SidebarGroup>
          <SidebarGroupLabel>Workspace</SidebarGroupLabel>
          <SidebarMenu>
            {items.map((item) => (
              <SidebarMenuItem key={item.href}>
                <SidebarMenuButton
                  tooltip={item.title}
                  isActive={pathname === item.href}
                  render={<Link href={item.href} />}
                >
                  <item.icon />
                  <span>{item.title}</span>
                </SidebarMenuButton>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
        </SidebarGroup>
      </SidebarContent>
      <SidebarRail />
    </Sidebar>
  );
}
```

The root layout only adds the `TooltipProvider` (the shadcn CLI prints a reminder about it when you add the sidebar) and never touches cookies:

```tsx
// app/layout.tsx
import type { Metadata } from "next";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";

export const metadata: Metadata = { title: "Sidebar test" };

// The root layout stays static: it never reads cookies.
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <TooltipProvider>{children}</TooltipProvider>
      </body>
    </html>
  );
}
```

Here is what the browser test recorded with this setup:

- First visit, no cookie: the sidebar rendered expanded at 256 px and no cookie was written yet.
- Clicking the trigger collapsed it to the 48 px icon rail and set `sidebar_state=false`.
- In the rail, hovering an icon showed its tooltip ("Revenue"). While expanded, no tooltip showed.
- Clicking a rail icon navigated to `/dashboard/revenue`, the item was marked active, and the rail stayed collapsed.
- After a reload the rail was still collapsed, and the HTML the server sent already had `data-state="collapsed"` with no `expanded` anywhere in it. That's why there's no flash.
- Ctrl+B expanded it again and wrote `sidebar_state=true`. The shortcut is built into `SidebarProvider` (Cmd+B on a Mac).
- At 390 px the sidebar became a slide-over sheet with all four links, nothing scrolled sideways, and opening the sheet didn't touch the cookie, which stayed `false`.
- No console errors in any run.

## The build error on Next.js 16

My first version read the cookie straight in the layout body, the way most guides show it:

```tsx
// app/(app)/layout.tsx (first try, fails to build with Cache Components on)
import { cookies } from "next/headers";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  // No cookie yet (first visit) means open. Only an explicit "false" collapses it.
  const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false";

  return (
    <SidebarProvider defaultOpen={defaultOpen}>
      <AppSidebar />
      <SidebarInset>
        <header className="flex h-12 items-center gap-2 border-b px-3">
          <SidebarTrigger />
          <span className="text-sm text-muted-foreground">Ctrl/Cmd + B</span>
        </header>
        <div className="p-6">{children}</div>
      </SidebarInset>
    </SidebarProvider>
  );
}
```

The fresh `create-next-app` 16.4.2 project ships with `cacheComponents: true` in `next.config.ts`, and with that setting `next build` stopped on the first dashboard route:

```text
Error: Route "/dashboard": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
```

Next prints three ways out: a Suspense boundary, `"use cache"` (the message aims it at fetches and database calls, and a cookie is per request), or `export const instant = false` to let the route block. I didn't try the last one. I went with Suspense, which is the working version at the top. The async part moves into a small `SidebarShell` component, and the layout wraps it in `<Suspense>`.

With that change the build passed and the dashboard routes showed up as Partial Prerender (the ◐ mark): a static shell is prerendered and the part that reads the cookie streams in the same response. In the browser test the fallback `div` was swapped out at about 176 ms and the first contentful paint came at 196 ms, so the empty fallback was never painted. That was on localhost. On a slow connection the fallback could show for a moment, so give it the page background and full height, like mine, rather than a spinner.

If your project has Cache Components off (Next.js 15, or 16 with the flag removed), both versions build. I checked: with `cacheComponents: false` the first-try layout and the Suspense layout each built, and the dashboard routes became dynamic (ƒ). The Suspense version works in both modes, so that's the one I'd keep.

## Why the route group matters

Calling `cookies()` makes the segment depend on the request. If you put it in `app/layout.tsx`, every page under it depends on the request too, including a landing page or pricing page that could have been static. In both build modes my `/` page stayed static (○) because the cookie read lives in `app/(app)/layout.tsx` and the landing page sits outside that group. Route groups in parentheses don't change URLs, so `/dashboard` is still `/dashboard`.

## First visit should be open

A lot of copies of this code check the cookie with `=== "true"`. That reads a missing cookie as false, so a brand new visitor gets the collapsed rail before they've ever touched the toggle. I ran the same layout with that check under a separate route, and on a first visit with no cookie the sidebar rendered collapsed at 48 px. With `!== "false"` it rendered open. Pick whichever default you want new users to see, but pick it on purpose.

## Things that tripped me up

- The cookie name is set at the top of `components/ui/sidebar.tsx` as `SIDEBAR_COOKIE_NAME`. Mine was `sidebar_state`, with a max age of seven days. If your copy of the file is older, check the name there before you read it in the layout.
- The CLI installed the Base UI version, so the menu button takes a `render` prop: `render={<Link href={item.href} />}`. Projects set up with the Radix style use `asChild` with a `<Link>` child instead. I only tested the Base UI version.
- Text you add yourself, like the workspace name in `SidebarHeader`, needs `group-data-[collapsible=icon]:hidden` or it gets squeezed into the rail. The group label and the menu button labels are already handled by the component.
- The tooltip only appears while the rail is collapsed and never on mobile. That's built into `SidebarMenuButton`, so you only pass `tooltip`.

## A sidebar where the page moves with it

shadcn's sidebar slides the rail in and leaves the content to take the space. qodesign's [Reflow Sidebar](/components/reflow-sidebar) is an animated app shell where the full sidebar springs down to an icon rail and the dashboard reflows with it: the KPI cards widen, the chart redraws and the table opens a column. It has rail tooltips, a phone overlay and Ctrl/Cmd+B. The [Next.js App Router recipe](/react/reflow-sidebar) uses the same cookie approach as this post so the rail survives a reload. If you're pasting components in with Cursor, [this post on adding a third-party component without breaking your design](/blog/cursor-add-ui-component-without-breaking-design) covers the parts that usually go wrong. Free pieces with full source are at [/free](/free).

## FAQ

### How do I keep the shadcn sidebar collapsed after a page reload?

The sidebar writes its state to the `sidebar_state` cookie on every toggle. Read that cookie with `cookies()` in the server layout that renders `SidebarProvider` and pass `defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}`. The server then sends the collapsed markup, so nothing flashes.

### Why does cookies() in my layout break next build on Next.js 16?

New Next.js 16 projects have Cache Components on, and reading request data like cookies outside a Suspense boundary stops prerendering with "Next.js encountered uncached or runtime data during prerendering". Move the cookie read into an async child component and wrap it in `<Suspense>`.

### How do I make the shadcn sidebar collapse to icons?

Set `collapsible="icon"` on `<Sidebar>`. Give each `SidebarMenuButton` a `tooltip` so people can still tell the icons apart, and hide any text you added yourself with `group-data-[collapsible=icon]:hidden`.

### Does reading the cookie make every page dynamic?

Only the pages under the layout that reads it. Put `SidebarProvider` and the cookie read in a route group layout such as `app/(app)/layout.tsx`, and pages outside the group, like your landing page, can stay static.
