From a2bcb29cef50b91f6373e5809e70fdf7fee05218 Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:47:24 -0600 Subject: [PATCH 1/4] build: Turbopack for next build, keep the contentlayer cache between Vercel deploys Vercel builds spend ~25-30s regenerating all 506 MDX documents on every deploy, because contentlayer only writes its cache to .contentlayer/.cache and Vercel only keeps .next/cache. dev/build-content.mjs symlinks the one into the other and runs contentlayer2 build, so only changed pages are re-rendered. With contentlayer run as its own step, next build no longer needs the next-contentlayer2 webpack hook, so it can use Turbopack (the Next 16 default): no --webpack, no webpack persistent cache to restore and upload, no 'Collecting build traces' step. next dev --webpack keeps the plugin through the PHASE_DEVELOPMENT_SERVER branch in next.config.js. Two things webpack tolerated that Turbopack does not: - src/proxy.ts loaded src/data/redirects.ts with require() of a file that mixes import and module.exports; it is now import/export on both sides, and dev/check-redirects.mjs strips the new export line - contentlayer.config.ts read the code theme with a path relative to where contentlayer compiles the config; it is now a JSON import esbuild bundles Amp-Thread-ID: https://ampcode.com/threads/T-01a0acbf-1cb1-7317-92d6-cd9399e6112a Co-authored-by: Amp --- AGENTS.md | 11 +++++++---- contentlayer.config.ts | 11 ++--------- dev/TODO.md | 9 +++++++++ dev/build-content.mjs | 31 +++++++++++++++++++++++++++++++ next.config.js | 9 ++++++++- package.json | 2 +- src/data/redirects.ts | 4 +--- src/proxy.ts | 3 +-- 8 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 dev/build-content.mjs diff --git a/AGENTS.md b/AGENTS.md index c2fd71c5a..e3070b2d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,13 @@ - **Lint**: `pnpm run lint` (ESLint 9 flat config in `eslint.config.mjs`; React Compiler rules are warnings until the vendored `src/components/search/docsearch` code is rewritten) -- **Framework**: Next.js 16 with `--webpack` (`next-contentlayer2` has no - Turbopack plugin); the request rewrite lives in `src/proxy.ts`; - `/api/releases` and `/api/versions` opt into static caching with - `export const dynamic = 'force-static'` +- **Framework**: Next.js 16. `next build` uses Turbopack; `dev/build-content.mjs` + runs `contentlayer2 build` first, with its cache under `.next/cache` so Vercel + keeps it between deploys. `next dev --webpack` still uses the + `next-contentlayer2` webpack plugin to regenerate content on change + (`next.config.js` applies it only in the dev phase). The request rewrite lives + in `src/proxy.ts`; `/api/releases` and `/api/versions` opt into static caching + with `export const dynamic = 'force-static'` - **Checks**: `pnpm run check` runs the checks in `dev/checks.mjs` (links, filenames, images); `pnpm run build` runs filenames and images first, so a finding from those fails a deploy. Links is not in the build: it runs as its diff --git a/contentlayer.config.ts b/contentlayer.config.ts index e87527b09..abc08c904 100644 --- a/contentlayer.config.ts +++ b/contentlayer.config.ts @@ -6,6 +6,7 @@ import rehypeSlug from 'rehype-slug'; import remarkGfm from 'remark-gfm'; import {MDXDocument, allCoreContent} from './src/utils/contentlayer'; import {searchMetadata} from './src/data/search'; +import shadesOfPurple from './src/styles/shades-of-purple.json'; import GithubSlugger from 'github-slugger'; import {visit} from 'unist-util-visit'; @@ -96,15 +97,7 @@ function createSearchIndex(allPosts: MDXDocument[]) { const prettyCodeOptions = { keepBackground: true, - theme: JSON.parse( - fs.readFileSync( - new URL( - './../../../src/styles/shades-of-purple.json', - import.meta.url - ), - 'utf-8' - ) - ) + theme: shadesOfPurple }; const rehypePlugins: any = [ diff --git a/dev/TODO.md b/dev/TODO.md index 03245b340..28c56da63 100644 --- a/dev/TODO.md +++ b/dev/TODO.md @@ -4,3 +4,12 @@ React Compiler ESLint rules `react-hooks/refs`, `react-hooks/set-state-in-effect` and `react-hooks/static-components` can go back to `error` in `eslint.config.mjs` (31 of the 39 warnings from `pnpm run lint` are in that directory) +- `pnpm run dev` (`next dev --webpack`) fails to serve pages on main since the + Next 16 upgrade: `ReferenceError: require is not defined` from + `next/src/build/webpack/loaders/postcss-loader`. Fix it, or move dev to + Turbopack by running `contentlayer2 dev` alongside `next dev` and dropping + the `next-contentlayer2` plugin from `next.config.js` entirely +- Faster Vercel static generation: the 505 `/api/og` images are ~40% of the + "Generating static pages" time (locally 6.6s with them, 3.6s without). + Options: hoist the font and logo reads in `src/app/api/og/[...path]/route.tsx` + to module scope, or render one image per top-level section instead of per page diff --git a/dev/build-content.mjs b/dev/build-content.mjs new file mode 100644 index 000000000..69db4f362 --- /dev/null +++ b/dev/build-content.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +/** + * Runs `contentlayer2 build` with its cache kept under .next/cache. + * + * Contentlayer only writes its cache to .contentlayer/.cache, and Vercel only + * keeps .next/cache between builds. Without this every deploy re-renders all + * ~500 MDX pages (~25s on Vercel's 4-core build machine); with it, only the + * pages whose source changed. + */ + +import {execFileSync} from 'child_process'; +import {lstatSync, mkdirSync, rmSync, symlinkSync} from 'fs'; +import path from 'path'; + +const cacheDir = path.join('.next', 'cache', 'contentlayer'); +const cacheLink = path.join('.contentlayer', '.cache'); + +mkdirSync(cacheDir, {recursive: true}); +mkdirSync('.contentlayer', {recursive: true}); + +// A real directory here is a cache from an earlier `next build --webpack`. +const existing = lstatSync(cacheLink, {throwIfNoEntry: false}); +if (existing && !existing.isSymbolicLink()) { + rmSync(cacheLink, {recursive: true}); +} +if (!existing || !existing.isSymbolicLink()) { + symlinkSync(path.relative('.contentlayer', cacheDir), cacheLink); +} + +execFileSync('contentlayer2', ['build'], {stdio: 'inherit'}); diff --git a/next.config.js b/next.config.js index 2603ca187..941a822af 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,4 @@ +const {PHASE_DEVELOPMENT_SERVER} = require('next/constants'); const {withContentlayer} = require('next-contentlayer2'); /** @type {import('next').NextConfig} */ @@ -35,4 +36,10 @@ const nextConfig = { } }; -module.exports = withContentlayer(nextConfig); +// withContentlayer is a webpack hook that regenerates .contentlayer when content +// changes, which `next dev --webpack` needs. `next build` uses Turbopack, with +// dev/build-content.mjs generating .contentlayer beforehand. +module.exports = phase => + phase === PHASE_DEVELOPMENT_SERVER + ? withContentlayer(nextConfig) + : nextConfig; diff --git a/package.json b/package.json index 684df3b5e..c40eaf290 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "scripts": { "dev": "next dev --webpack", - "build": "node dev/checks.mjs filenames images && node dev/generate-mermaid-icons.mjs && next build --webpack", + "build": "node dev/checks.mjs filenames images && node dev/generate-mermaid-icons.mjs && node dev/build-content.mjs && next build", "start": "next start", "lint": "eslint src", "check": "node dev/checks.mjs", diff --git a/src/data/redirects.ts b/src/data/redirects.ts index 1a886b582..3a8354f45 100644 --- a/src/data/redirects.ts +++ b/src/data/redirects.ts @@ -4287,6 +4287,4 @@ const updatedRedirectsData = redirectsData.map(redirect => { }; }); -module.exports = { - updatedRedirectsData -}; +export {updatedRedirectsData}; diff --git a/src/proxy.ts b/src/proxy.ts index 3d4e2bc10..f3d1e3cda 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,8 +3,7 @@ import {NextResponse} from 'next/server'; import docsConfig from '../docs.config.js'; import {TECHNICAL_CHANGELOG_RSS_URL} from './data/constants'; - -const {updatedRedirectsData} = require('./data/redirects.ts'); +import {updatedRedirectsData} from './data/redirects'; function createRedirectUrl( request: NextRequest, From 76574bc4907c4e5b0f260023bfd3ac59b8df121a Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:24:36 -0600 Subject: [PATCH 2/4] build: Set .mdx mtimes from content so contentlayer's cache hits on Vercel contentlayer2 keys its document cache on the source file's mtime, and a fresh clone sets every mtime to the clone time, so on Vercel every entry missed. The second preview deploy of this branch restored the cache and still spent 23s re-rendering all 506 documents. Amp-Thread-ID: https://ampcode.com/threads/T-01a0ad75-46cf-77ed-889b-438e2521bc89 Co-authored-by: Amp --- AGENTS.md | 13 +++++++----- dev/build-content.mjs | 48 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3070b2d0..8ae767663 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,14 @@ `src/components/search/docsearch` code is rewritten) - **Framework**: Next.js 16. `next build` uses Turbopack; `dev/build-content.mjs` runs `contentlayer2 build` first, with its cache under `.next/cache` so Vercel - keeps it between deploys. `next dev --webpack` still uses the - `next-contentlayer2` webpack plugin to regenerate content on change - (`next.config.js` applies it only in the dev phase). The request rewrite lives - in `src/proxy.ts`; `/api/releases` and `/api/versions` opt into static caching - with `export const dynamic = 'force-static'` + keeps it between deploys, and with each `.mdx` file's mtime set from its + content, since contentlayer2 keys its cache on mtime and a fresh clone resets + those (workaround; drop once + ships). `next dev --webpack` still uses the `next-contentlayer2` webpack plugin + to regenerate content on change (`next.config.js` applies it only in the dev + phase). The request rewrite lives in `src/proxy.ts`; `/api/releases` and + `/api/versions` opt into static caching with + `export const dynamic = 'force-static'` - **Checks**: `pnpm run check` runs the checks in `dev/checks.mjs` (links, filenames, images); `pnpm run build` runs filenames and images first, so a finding from those fails a deploy. Links is not in the build: it runs as its diff --git a/dev/build-content.mjs b/dev/build-content.mjs index 69db4f362..fdb6798a2 100644 --- a/dev/build-content.mjs +++ b/dev/build-content.mjs @@ -1,21 +1,57 @@ #!/usr/bin/env node /** - * Runs `contentlayer2 build` with its cache kept under .next/cache. + * Runs `contentlayer2 build` so that its cache works on Vercel. * - * Contentlayer only writes its cache to .contentlayer/.cache, and Vercel only - * keeps .next/cache between builds. Without this every deploy re-renders all - * ~500 MDX pages (~25s on Vercel's 4-core build machine); with it, only the - * pages whose source changed. + * Without this every deploy re-renders all ~500 MDX pages (~25s on Vercel's + * 4-core build machine); with it, only the pages whose source changed. Two + * things stand in the way: + * + * - Contentlayer only writes its cache to .contentlayer/.cache, and Vercel only + * keeps .next/cache between builds. So .contentlayer/.cache is a symlink into + * .next/cache. + * - Contentlayer decides whether a cached document is current by comparing the + * source file's mtime, and a fresh git clone sets every mtime to the clone + * time, so on Vercel every entry misses. So each source file's mtime is set + * from a hash of its content, which is the same wherever the same content is + * checked out. This is a workaround for contentlayer2 keying its cache on + * mtime (@contentlayer2/source-files, makeCacheItemFromFilePath.ts) and can + * go once https://github.com/timlrx/contentlayer2/pull/94 ships. */ import {execFileSync} from 'child_process'; -import {lstatSync, mkdirSync, rmSync, symlinkSync} from 'fs'; +import {createHash} from 'crypto'; +import { + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + utimesSync +} from 'fs'; import path from 'path'; +const contentDir = 'docs'; const cacheDir = path.join('.next', 'cache', 'contentlayer'); const cacheLink = path.join('.contentlayer', '.cache'); +// Workaround: content-derived mtimes so contentlayer's cache hits on a fresh +// clone. Remove once https://github.com/timlrx/contentlayer2/pull/94 ships. +for (const entry of readdirSync(contentDir, { + recursive: true, + withFileTypes: true +})) { + if (!entry.isFile() || !entry.name.endsWith('.mdx')) continue; + const file = path.join(entry.parentPath, entry.name); + // Whole seconds, so the value survives any filesystem's timestamp precision + const seconds = createHash('sha1') + .update(readFileSync(file)) + .digest() + .readUInt32BE(0); + utimesSync(file, seconds, seconds); +} + mkdirSync(cacheDir, {recursive: true}); mkdirSync('.contentlayer', {recursive: true}); From 199d185e399e4c9e7ebebf8a88e34e9c5b6a4c6f Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:17:40 -0600 Subject: [PATCH 3/4] spelling: Allow mtimes and timlrx Amp-Thread-ID: https://ampcode.com/threads/T-01a0ad75-46cf-77ed-889b-438e2521bc89 Co-authored-by: Amp --- cspell-allow-list.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell-allow-list.txt b/cspell-allow-list.txt index 73fb24a78..6c542b5a9 100644 --- a/cspell-allow-list.txt +++ b/cspell-allow-list.txt @@ -320,6 +320,7 @@ mmap modelconfig mountpoint mpim +mtimes multiplicatively multiqueue multiversion @@ -543,6 +544,7 @@ thorstens threadcreate timedout timemachine +timlrx tini tjdevries tolerations From 0ab4acdd419edaaf55fbdbd12f352217091baddd Mon Sep 17 00:00:00 2001 From: Marc LeBlanc <7050295+marcleblanc2@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:00:44 -0600 Subject: [PATCH 4/4] dev/todo: Note the webpack to Turbopack dev migration Amp-Thread-ID: https://ampcode.com/threads/T-01a0ad75-46cf-77ed-889b-438e2521bc89 Co-authored-by: Amp --- dev/TODO.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dev/TODO.md b/dev/TODO.md index 28c56da63..73ff2cea5 100644 --- a/dev/TODO.md +++ b/dev/TODO.md @@ -4,11 +4,12 @@ React Compiler ESLint rules `react-hooks/refs`, `react-hooks/set-state-in-effect` and `react-hooks/static-components` can go back to `error` in `eslint.config.mjs` (31 of the 39 warnings from `pnpm run lint` are in that directory) -- `pnpm run dev` (`next dev --webpack`) fails to serve pages on main since the - Next 16 upgrade: `ReferenceError: require is not defined` from - `next/src/build/webpack/loaders/postcss-loader`. Fix it, or move dev to - Turbopack by running `contentlayer2 dev` alongside `next dev` and dropping - the `next-contentlayer2` plugin from `next.config.js` entirely +- Move `pnpm run dev` from webpack to Turbopack, like `pnpm run build`. The only + thing keeping it on webpack is the `next-contentlayer2` plugin, which + regenerates `.contentlayer` when an `.mdx` file changes; Turbopack has no + plugin hook for that. Needs a way to start contentlayer's watcher + (`contentlayer2 dev`) automatically when `next dev` starts, then the plugin + and the `PHASE_DEVELOPMENT_SERVER` branch in `next.config.js` can go - Faster Vercel static generation: the 505 `/api/og` images are ~40% of the "Generating static pages" time (locally 6.6s with them, 3.6s without). Options: hoist the font and logo reads in `src/app/api/og/[...path]/route.tsx`