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

6 min read

TL;DR 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.

On this page
  1. The setup that works
  2. The build error on Next.js 16
  3. Why the route group matters
  4. First visit should be open
  5. Things that tripped me up
  6. A sidebar where the page moves with it
  7. FAQ
  8. How do I keep the shadcn sidebar collapsed after a page reload?
  9. Why does cookies() in my layout break next build on Next.js 16?
  10. How do I make the shadcn sidebar collapse to icons?
  11. Does reading the cookie make every page dynamic?

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, 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 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 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 covers the parts that usually go wrong. Free pieces with full source are at /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.

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.