How I Cut Neon's Docs Build Time by ~30%
I recently optimized the docs build times for neon.com (by @neondatabase) and cut static generation from ~21s to ~14s across 2,328 pages.
That is roughly a 33% drop, from a 16-line change: https://github.com/neondatabase/website/pull/5570.
Let me walk you through it:
1. Where the Build Time Was Going
The docs are statically generated, so every page is prerendered at build time and build time scales with page count. The build prerendered 2,328 pages.
The docs layout has two navigations. The desktop sidebar that lists the full docs tree. On mobile, the same tree is inside a drawer, an overlay that stays hidden until you tap it open.
Both were built the same way at prerender time:
const menu = useMemo(
() => transformNavigation(navigation || [], basePath),
[navigation, basePath]
);
if (!menu.length) return null;
In the code above, transformNavigation walks down the entire tree, roughly 1,700 nodes, and turns it into rendered output. Doing that once is fine. The catch was that this component rendered on every page, so the tree was being built and serialized 2,328 times over.
When I profiled static generation, this one drawer was about 35% of the total. A third of the build went into a menu that is invisible on first paint and duplicates links the desktop sidebar already puts in the HTML.
2. Deferring the Mobile Nav Render
The drawer is a client-side overlay. Its contents do not need to exist until someone opens it. So I build the tree on first open, and prerendering only emits the trigger button:
const [hasOpened, setHasOpened] = useState(false);
const onOpenChange = useCallback((next) => {
setOpen(next);
if (next) setHasOpened(true);
}, []);
And the transform gated on it:
const menu = useMemo(
() => (hasOpened ? transformNavigation(navigation || [], basePath) : []),
[hasOpened, navigation, basePath]
);
if (!navigation || !navigation.length) return null;
At build time hasOpened is false, menu is an empty array, and the 1,700-node tree does not get built. The reader still gets the trigger in the prerendered HTML. When clicked, it will builds the tree on the client side.
3. Keeping It Safe
The early return now checks navigation instead of the built menu, so a page with a valid tree still renders its trigger before the transform runs. And the list only mounts once there is something to show:
{menu.length > 0 && (
<RecursiveList nodes={menu} currentPath={normalizeDocNavigationPath(pathname)} />
)}
The desktop sidebar already server-renders every one of those links, so crawlers see the same navigation as before. Mobile readers also see the same drawer, the difference being that it visually fills up only when they open it.
Summary
A few lines of deferral:
- The mobile nav was rendering a 1,700-node tree into all 2,328 pages
- That was ~35% of static generation, all of it wasted at build time
- Moving it to first open took the build from ~21s to ~14s