# A collapsible sidebar that remembers its state without a flash

> Saving the sidebar's open or collapsed state in localStorage makes every page load jump. A cookie the server reads renders the right width in the first HTML.

A sidebar that collapses to icons is a small feature with a common bug. The
first version saves the choice in `localStorage`:

```tsx
const [open, setOpen] = useState(() => localStorage.getItem("sidebar") !== "false");
```

That throws on the server (there is no `localStorage`), so it becomes:

```tsx
const [open, setOpen] = useState(true);
useEffect(() => {
  setOpen(localStorage.getItem("sidebar") !== "false");
}, []);
```

And now every page load for someone who collapsed the sidebar goes like this:
the server renders it open, the browser paints it open, React hydrates, the
effect runs, and it snaps shut. The content column jumps sideways by 13rem.
On a slow phone it is visible for a quarter of a second, on every navigation
that does a full load.

## Why it happens

The server renders the first HTML, and the server cannot read
`localStorage`. Any state that only the browser knows is unknown for the
first paint, so the server guesses. If the guess is wrong, the page changes
after it appears.

## Store it where the server can read it

A cookie goes to the server with every request. Write the state to a cookie
when it changes:

```ts
document.cookie = `sidebar_state=${open}; path=/; max-age=${60 * 60 * 24 * 7}; samesite=lax`;
```

and read it in the layout that renders the sidebar:

```tsx
// app/(app)/layout.tsx
const cookieStore = await cookies();
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false";

return <SidebarProvider defaultOpen={defaultOpen}>{/* ... */}</SidebarProvider>;
```

The first HTML now has the right width. Nothing moves after it appears.

This is the approach shadcn/ui's sidebar takes, and the reason its provider
accepts `defaultOpen`.

## Isn't reading cookies in a layout expensive?

Reading `cookies()` makes the route dynamic: it renders per request instead
of once at build time. For a signed-in area that is already true, because the
layout reads the session, which is also a cookie. You pay nothing extra.

For a public, static page, do not do this. There the right answer is CSS:
render both states and let a class on `<html>` pick one, set by a tiny inline
script before first paint (the way theme switchers avoid a light flash in
dark mode).

## The phone is a separate state

On a phone the sidebar is a sheet over the page, closed by default. Do not
persist that one: someone who opened the menu, tapped a link and reloaded
does not expect the menu to be open again. Keep two pieces of state, the
desktop `open` (persisted) and the mobile `openMobile` (not), and pick by
viewport.

Detect the viewport with `useSyncExternalStore` over `matchMedia`, not with
state set in an effect:

```ts
useSyncExternalStore(subscribe, () => matchMedia("(max-width: 767px)").matches, () => false);
```

The server snapshot is `false`, so the server renders the desktop markup,
which CSS hides below the breakpoint. After hydration the hook reports the
real value in the same commit, with no extra render showing the wrong
layout.

## Close the sheet on navigation

A sheet over the page stays open after someone taps a link inside it, unless
you close it. Closing it in each link's `onClick` misses the other ways a
route changes: a menu item, a redirect after a form, the back button. Watch
the pathname instead, and close the sheet when it changes.

## Checklist

- Desktop state in a cookie, read on the server and passed as `defaultOpen`.
- Mobile sheet state in memory only.
- Viewport from `useSyncExternalStore`, server snapshot `false`.
- The sheet closes when the pathname changes.
- A keyboard shortcut (Cmd+B) that ignores key presses inside text fields.

---

Agentic Boilerplate: A Next.js repo your agent already knows. $99 once. Lifetime access and updates.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
