---
title: How I Cut Neon's Docs Build Time by ~30%
description: I recently optimized the build times for Neon website and cut static generation from **~21s to ~14s** across 2,328 pages (about a 33% drop).
url: https://rishi.app/blog/how-i-cut-neon-docs-build-time-by-30-percent
author: Rishi Raj Jain
published_at: 2026-08-26T17:19:57.162Z
updated_at: 2026-10-14T22:26:37.602Z
---

# How I Cut Neon's Docs Build Time by ~30%

I recently optimized the docs build times for [neon.com](https://neon.com) (by [@neondatabase](https://twitter.com/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](https://github.com/neondatabase/website/pull/5570).

Let me walk you through it:

- [1. Where the Build Time Was Going](#1-where-the-build-time-was-going)
- [2. Deferring the Mobile Nav Render](#2-deferring-the-mobile-nav-render)
- [3. Keeping It Safe](#3-keeping-it-safe)

<hr class="w-full" />

## 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:

```jsx
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.

<hr class="w-full" />

## 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:

```jsx
const [hasOpened, setHasOpened] = useState(false);

const onOpenChange = useCallback((next) => {
  setOpen(next);
  if (next) setHasOpened(true);
}, []);
```

And the transform gated on it:

```jsx
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.

<hr class="w-full" />

## 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:

```jsx
{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.

<hr class="w-full" />

## 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

---

This is the Markdown version of https://rishi.app/blog/how-i-cut-neon-docs-build-time-by-30-percent. Every page on this site is listed with a summary at https://rishi.app/sitemap.md.
