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:
// 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:
// 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:
// 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 noexpandedanywhere in it. That's why there's no flash. - Ctrl+B expanded it again and wrote
sidebar_state=true. The shortcut is built intoSidebarProvider(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.
