A sidebar that collapses to icons is a small feature with a common bug. The
first version saves the choice in localStorage:
const [open, setOpen] = useState(() => localStorage.getItem("sidebar") !== "false");
That throws on the server (there is no localStorage), so it becomes:
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:
document.cookie = `sidebar_state=${open}; path=/; max-age=${60 * 60 * 24 * 7}; samesite=lax`;
and read it in the layout that renders the sidebar:
// 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:
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 snapshotfalse. - The sheet closes when the pathname changes.
- A keyboard shortcut (Cmd+B) that ignores key presses inside text fields.