Skip to content

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.

Next.js on Vercel3 min readships at docs/solutions/nextjs-vercel/sidebar-state-without-a-flash.md

Tags: nextjs · ui · cookies · hydration · sidebar

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 snapshot false.
  • The sheet closes when the pathname changes.
  • A keyboard shortcut (Cmd+B) that ignores key presses inside text fields.