The Rules That Moved safedep.io from Astro to Next.js 16

Arunanshu Biswas16 min read

We moved safedep.io, about 3,000 pages across the marketing site, the blog and the threat intelligence hub, from Astro to Next.js 16. Before any agent touched the code, I wrote down what correct code looks like on this site and set the build to reject everything else. With that in place, coding agents wrote most of the 536 commits and reached parity in five days. That groundwork, and the weeks of hand-tuning after parity, are what this post is about.

The result clicks between pages up to 2.4 times faster on a slow connection. It loads no third-party scripts, scores 100 on accessibility, and can run code on the server. It ships more JavaScript than the Astro site did, and that was a deliberate purchase.

Two kinds of rules carried the project. The first are rendering rules for Next.js 16:

  1. Cache the data, not the component. Put 'use cache' on the function that loads a record, never on a page or a component that holds a <Suspense>.
  2. Read params inside <Suspense>, even when generateStaticParams lists every value.
  3. Any uncached await makes that part of the page per-request. Next.js doesn’t have a time budget for an asynchronous/uncached call.
  4. Pick one ensureStatic level per dynamic route on day one (how to choose).
  5. Turn on cacheComponents, partialPrefetching and cachedNavigations for instant navigations between prerendered pages.
  6. Prefetch on intent (hover, focus, touch) when a page holds runtime data.

The second kind are code rules, and they matter more. Each thing has one right way to be written. Lint and CI enforce it, so neither people nor agents can ship the second way (the rules).

Commits per hour, October 6 to 10 (UTC)

536 of 691 commits landed in these five days, up to 36 in one hour. The other 155 came over the following month. On a desktop, hover a bar for the hour and one commit from it.

2026-10-06 00:00 UTC: 0 commits2026-10-06 01:00 UTC: 0 commits2026-10-06 02:00 UTC: 0 commits2026-10-06 03:00 UTC: 0 commits2026-10-06 04:00 UTC: 0 commits2026-10-06 05:00 UTC: 0 commits2026-10-06 06:00 UTC: 0 commits2026-10-06 07:00 UTC: 0 commits2026-10-06 08:00 UTC: 0 commits2026-10-06 09:00 UTC: 13 commits ea339e04 add orpc skill2026-10-06 10:00 UTC: 4 commits c4c27636 fix vscode css interop2026-10-06 11:00 UTC: 1 commit ce68a833 add tanstack query wiring2026-10-06 12:00 UTC: 4 commits c132381e add req and resp header plugin2026-10-06 13:00 UTC: 5 commits 5a73d571 remove get and head2026-10-06 14:00 UTC: 6 commits 6afdaabd remove view transition2026-10-06 15:00 UTC: 1 commit f107ad56 add ahooks2026-10-06 16:00 UTC: 1 commit 18c3a9bf make orpc client exported2026-10-06 17:00 UTC: 4 commits 307bd9b4 docs: note consent-record gap for offline c15t2026-10-06 18:00 UTC: 24 commits a91e08d4 test(parity): assert page status, never seed baselines from the new site2026-10-06 19:00 UTC: 9 commits 5866d0f1 blog: map MDX anchors to fumadocs-core Link (with NextProvider) instead of a hand2026-10-06 20:00 UTC: 5 commits dd80989e blog: tables scroll inside the column; inline code without prose backticks, wraps2026-10-06 21:00 UTC: 1 commit 54163df5 inline tiny chrome SVGs via SVGR (?svgr) to stop pop-in2026-10-06 22:00 UTC: 0 commits2026-10-06 23:00 UTC: 0 commits2026-10-07 00:00 UTC: 0 commits2026-10-07 01:00 UTC: 0 commits2026-10-07 02:00 UTC: 0 commits2026-10-07 03:00 UTC: 0 commits2026-10-07 04:00 UTC: 0 commits2026-10-07 05:00 UTC: 0 commits2026-10-07 06:00 UTC: 2 commits 8c1c892f better turbopack settings2026-10-07 07:00 UTC: 0 commits2026-10-07 08:00 UTC: 2 commits 4b39b459 fix audit2026-10-07 09:00 UTC: 0 commits2026-10-07 10:00 UTC: 12 commits c6553e40 docs(porting): close mdx-ctas rows and log CTA/zoom deviations2026-10-07 11:00 UTC: 4 commits 34e1186b docs(porting): correct mdx-ctas §12 notes (review round 2)2026-10-07 12:00 UTC: 9 commits b90d4638 docs(porting): close AffectedWidget rows and log gate/prose deltas2026-10-07 13:00 UTC: 14 commits c1c15f85 fix(blog): post h1 md leading 1.1; log list 404 metadata and code-block wrap2026-10-07 14:00 UTC: 9 commits 41b4bc3c docs(porting): §0 read-first rules and known mistakes; typography-only prose; no 2026-10-07 15:00 UTC: 14 commits c6706581 feat(branded): outline-subtle brand button for the star and Discord chips2026-10-07 16:00 UTC: 0 commits2026-10-07 17:00 UTC: 3 commits 0307ff2b docs(porting): U0 review round 1 rules and carry-forwards2026-10-07 18:00 UTC: 10 commits 9213f680 fix(blog): post hr dots get a height; same-site check needs the slash2026-10-07 19:00 UTC: 3 commits e5adb10f fix(blog): same-site post links navigate client-side; th keeps body tracking2026-10-07 20:00 UTC: 14 commits d814ce8a reconfigure nextjs2026-10-07 21:00 UTC: 8 commits 1c10515c docs(porting): user data rule in §0 and §4; U4 final parity rows; lint overrides 2026-10-07 22:00 UTC: 9 commits 833ad5ce docs(porting): U5 r2 controls row and a §13 P5 AffectedWidget error-path item2026-10-07 23:00 UTC: 11 commits 1a62e078 docs(porting): U1 parity r1 §12 notes (consent buttons and backdrop, mobile menu)2026-10-08 00:00 UTC: 10 commits c22e9316 fix(video): skip the mp4 entirely under reduced motion2026-10-08 01:00 UTC: 0 commits2026-10-08 02:00 UTC: 0 commits2026-10-08 03:00 UTC: 0 commits2026-10-08 04:00 UTC: 0 commits2026-10-08 05:00 UTC: 0 commits2026-10-08 06:00 UTC: 3 commits 84b466fc docs(porting): cleanup result and integration parity run2026-10-08 07:00 UTC: 1 commit 73f7f89d fix config2026-10-08 08:00 UTC: 1 commit d627bb98 perf optimization2026-10-08 09:00 UTC: 7 commits 28e10cec docs(porting): log a11y-infra fix round (brand fills, counters, Button lookup)2026-10-08 10:00 UTC: 8 commits 9a3df0ab fix(home): keep the hero pause toggle inside the 1200px frame2026-10-08 11:00 UTC: 7 commits e384afba docs(porting): known mistake: literal ids for aria wiring; useId2026-10-08 12:00 UTC: 1 commit 814285eb fix(home): give the footer's Features link a target2026-10-08 13:00 UTC: 36 commits 736ea874 docs(porting): solutions-a rows, deviations and open items2026-10-08 14:00 UTC: 1 commit 616a91c0 fix(code-tabs): AA comment colour in highlighted code2026-10-08 15:00 UTC: 27 commits bfacbda2 fix(how-it-works): scroll the dialog body so Close stays in view2026-10-08 16:00 UTC: 18 commits 6c9e569c refactor(external-mark): write the word joiner as a visible \u2060 escape2026-10-08 17:00 UTC: 14 commits a0f76532 docs(porting): blog-list metadata item adds the RSS alternate via pageMetadata2026-10-08 18:00 UTC: 1 commit 5325c03d docs(porting): known mistakes from wave 3 integration (Route generics, parallel d2026-10-08 19:00 UTC: 2 commits 77a3228e build: turn off prerender and server source maps to keep next build within memory2026-10-08 20:00 UTC: 0 commits2026-10-08 21:00 UTC: 0 commits2026-10-08 22:00 UTC: 0 commits2026-10-08 23:00 UTC: 0 commits2026-10-09 00:00 UTC: 0 commits2026-10-09 01:00 UTC: 0 commits2026-10-09 02:00 UTC: 0 commits2026-10-09 03:00 UTC: 0 commits2026-10-09 04:00 UTC: 0 commits2026-10-09 05:00 UTC: 0 commits2026-10-09 06:00 UTC: 0 commits2026-10-09 07:00 UTC: 0 commits2026-10-09 08:00 UTC: 10 commits f4c012ff docs(ti): authoring README, CSV and memory trade-offs, corrected §12 rows2026-10-09 09:00 UTC: 7 commits a27cc4f5 fix(ti): full-page TTP skeleton, honest segment comments, no no-op badge class2026-10-09 10:00 UTC: 10 commits c1280a18 docs(porting): SafeDep APIs go through the generated client and lib/safedep-api.t2026-10-09 11:00 UTC: 13 commits c380a855 docs(ti): filter-bar remount tradeoff, prefetch decision, harness evidence2026-10-09 12:00 UTC: 10 commits 167852c3 docs(porting): permalinks skip the 308; instrumentation client tried and reverted2026-10-09 13:00 UTC: 11 commits d0e774cb refactor(ti): every TI link is a typed string; pendingRoutes is gone2026-10-09 14:00 UTC: 2 commits ab392183 fix(ti): record generateMetadata raises notFound(); §4 and §12 updated2026-10-09 15:00 UTC: 0 commits2026-10-09 16:00 UTC: 2 commits 014b1417 docs(porting): quality pass audit and plan2026-10-09 17:00 UTC: 10 commits 748bdedf docs(porting): quality pass audit, corrected after Q12026-10-09 18:00 UTC: 9 commits d09858ba chore(lint): word the assertion comment so code has no `as Route`2026-10-09 19:00 UTC: 7 commits 7796761a fix(links): allow only mailto: and tel:; guard the parser pins2026-10-09 20:00 UTC: 19 commits 9a660f89 docs(porting): mark the ast-grep-enforced rows; log the unported oRPC helpers2026-10-09 21:00 UTC: 9 commits 5f015924 fix(newsletter): a non-OK Apollo response is an outage, logged at error2026-10-09 22:00 UTC: 0 commits2026-10-09 23:00 UTC: 0 commits2026-10-10 00:00 UTC: 0 commits2026-10-10 01:00 UTC: 0 commits2026-10-10 02:00 UTC: 0 commits2026-10-10 03:00 UTC: 0 commits2026-10-10 04:00 UTC: 0 commits2026-10-10 05:00 UTC: 5 commits 92137023 test(e2e): first request to an unknown record param is a 404 under next start2026-10-10 06:00 UTC: 8 commits e83f44e4 docs(porting): P4 cutover plan2026-10-10 07:00 UTC: 11 commits 4392b16f docs(ti): README check 2 names the kebab-case file name rule2026-10-10 08:00 UTC: 11 commits 3ad0d8d9 fix(ti): reaper paths and Miasma CSV, narrower gitleaks allowlist, AR Vale filter2026-10-10 09:00 UTC: 2 commits ce7a59aa docs(porting): CI section, visual baseline flow, flake root cause2026-10-10 10:00 UTC: 1 commit 0a3ee2ea ci: actionlint in checks; build installs only node; pre-cutover baseline refresh2026-10-10 11:00 UTC: 3 commits cf817cb5 ci: pin Playwright to 4 workers; visual job only on PRs and v2 pushes2026-10-10 12:00 UTC: 4 commits e96e2ae9 fix(orpc): log unexpected procedure errors through evlog, not the console2026-10-10 13:00 UTC: 9 commits 4a8c17c7 ci: run every job on ubuntu-latest2026-10-10 14:00 UTC: 6 commits abe087dd docs(porting): P4 unit 5 parity results, §13 triage and the cutover checklist2026-10-10 15:00 UTC: 5 commits cb3fbc66 docs(porting): log the review round 2 minor findings2026-10-10 16:00 UTC: 4 commits 611a0384 perf(lottie): load the player only when its box nears the viewport2026-10-10 17:00 UTC: 4 commits 9b826a29 refactor(lottie): latch on the first crossing and stop observing2026-10-10 18:00 UTC: 3 commits ee2c0e60 refactor(safedep-api): per-service client factories for the community tenant2026-10-10 19:00 UTC: 2 commits 3123808d docs(porting): CI build-time breakdown and Lighthouse noise, measured2026-10-10 20:00 UTC: 0 commits2026-10-10 21:00 UTC: 0 commits2026-10-10 22:00 UTC: 0 commits2026-10-10 23:00 UTC: 0 commits

The limits of Astro, or outgrowing your tech stack

The old site was an AstroWind fork deployed to Cloudflare Pages: 202 .astro files and about 19,000 lines of templates, rendered to static HTML at build time. For a content site that is a reasonable design, and Astro ships almost no JavaScript by default. Our problems came from what the site had grown into.

Interactive parts were written as inline scripts against the DOM. Nothing in the stack defined what correct code looked like, let alone enforced it, so contributors, and later their agents, wrote whatever worked. This is the tab switcher and copy button from the MCP setup page:

src/components/solutions/mcp/MCPSetupSteps.astro @ 47a82e1 (lines 364-393)
      const setActiveTab = (nextTab: HTMLElement) => {
        const tabId = nextTab.getAttribute('data-tab');
        if (!tabId) return;

        tabs.forEach((tab) => {
          const selected = tab === nextTab;
          tab.classList.toggle('text-white', selected);
          tab.classList.toggle('text-neutral-400', !selected);
          tab.setAttribute('aria-selected', selected ? 'true' : 'false');
          tab.setAttribute('tabindex', selected ? '0' : '-1');
        });

        panels.forEach((panel) => {
          const isActive = panel.getAttribute('data-content') === tabId;
          panel.classList.toggle('hidden', !isActive);
        });
      };

      tabs.forEach((tab, index) => {
        tab.addEventListener('click', () => setActiveTab(tab));
        tab.addEventListener('keydown', (event: KeyboardEvent) => {
          if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
          event.preventDefault();
          const delta = event.key === 'ArrowRight' ? 1 : -1;
          const nextIndex = (index + delta + tabs.length) % tabs.length;
          const nextTab = tabs[nextIndex] as HTMLElement;
          setActiveTab(nextTab);
          nextTab.focus();
        });
      });

Sure, it works. It is also a hand-rolled tabs widget with its own ARIA, its own focus handling and its own colours, and the site had many of these. React was available through islands, but once every interactive part wants React, an islands framework is the wrong tool for the job.

The second limit was the server. The output was static, so anything that needed a server was off the table: server-side tests, server-rendered pages, feature flags, incremental regeneration. We could not mix prerendered pages with server-rendered ones. Astro has SSR adapters, but with one we would still be writing request handlers. If I am writing request handlers anyway, Go with templ would give us a smaller binary than either framework.

The third limit was the third-party scripts layered on top of the site. Every page loaded about 740 KiB of them: Google Tag Manager, gtag.js twice, and PostHog from PostHog’s own servers, where any ad blocker could stop it. Until October 3 the snippet also sampled only 75% of visits.

But why Next.js?

The honest answer starts with our requirements. Our app website app.safedep.io(opens in new tab) is a Next.js app, and I have run Next.js in production for years. What I wanted from the new site was specific. Navigations between pages should feel instant without shipping a single-page app, some content should render on the server, and feature flags and ISR should be one decision away. The table records what we weighed.

Next.js costs more JavaScript than Astro, and the last section puts a number on it. We bought instant navigations and a server with it, which is what we came for.

One right way to do things

The execution of the laws is more important than the making of them.

It’s not that the Astro site had no rules. It had no way of enforcing them, so the easy path won, and when agents joined they took the easy path faster. They learn from the code in front of them. If the codebase shows that nobody cares about readability, an agent has no reason to care either.

So before any agent ported anything, I wrote down what correct code looks like on this site and made the build reject the rest. None of these rules came from an agent. Each one came from a codebase that hurt to maintain.

  • No useEffect. I have seen people and agents write effect bodies that nobody could follow a month later. State gets copied from props, effects trigger other effects, and event logic hides in dependency arrays. So the hook is banned outright, along with useLayoutEffect and useInsertionEffect, through no-restricted-imports. The lint message tells you what to do instead: Derive the value, move the work into an event handler, or use useMount from ahooks. Shared state goes through context. Five rules from eslint-plugin-react-you-might-not-need-an-effect guard the few files exempted from the ban.
  • Complexity has a ceiling. A component’s cyclomatic complexity stops at 15, callbacks nest at most three deep, and a function takes at most four parameters, after which you pass one named object. I brought these thresholds over from the golangci-lint setup I introduced in our Go backend (cyclop, gocognit, nestif). Go codebases rot the same way without them, and the same limit of 15 holds there.
  • Module boundaries are lint rules. lib/analytics.ts is the only module allowed to import posthog-js, through no-restricted-imports in .oxlintrc.json. Analytics goes through one entry point, which sends events to a first-party /ingest proxy, so ad blockers no longer decide what we measure. Shout out to depguard(opens in new tab) for the inspiration.
  • Style rules are ast-grep rules. A hex colour or a -[13px] class anywhere in a class string fails the pre-commit hook. A hand-written target="_blank" outside the branded link components fails too.
  • Links are typed. typedRoutes turns a link to a page that does not exist into a type error, and a test checks every internal link in every post.
  • Dead code is found. knip reports unused files, exports and dependencies.
  • The build checks the rendering. Blog routes use ensureStatic = 'navigation', so any uncached work on them fails next build.

A threshold turns “this is messy” from an opinion into a failed check. The same checks run in the pre-commit hook and again in CI, so “ship it now, clean it up later” is no longer an option anyone can choose. The code either passes or it does not merge.

The same CI is why we run about 15 experimental Next.js flags in production without worrying much. Each one sits behind parity specs, an instant-navigation test, Lighthouse runs on every pull request, and a one-click rollback on Vercel.

Porting 3,000 pages in 5 days

Now with rules in hand, the port itself became mechanical. The method came from Jarred Sumner’s account of rewriting Bun in Rust(opens in new tab). Write the rules down before anyone ports anything. Give agents a queue of small units then let tests decide what is done.

The rules live in docs/PORTING.md. 946 lines of unambiguous instructions, of which here is an excerpt:

docs/PORTING.md @ 5e2aaf22 (lines 16-24)
An agent is only as good as its instructions, and literal readings of this file
have produced worse code than the old site. These rules come first; when
anything below seems to demand otherwise, stop and report it as a blocker
instead of complying.

**Think before coding.** Write the component's intent [...] and your
assumptions before writing code. When two readings of a rule exist, name both.
Pick the one that leaves the site better, and say which you picked. If this file
tells you to do something that produces worse code than the framework default,
say so in your report; do not follow it to the letter.

**Simplicity first.** The best code is code you did not write. Before writing
anything, check whether Next, Tailwind, the typography plugin, Base UI, shadcn
or an installed dependency already does it [...]. No speculative props, variants
or options without a consumer. If 200 lines could be 50, rewrite them.

**Surgical changes.** Touch only the files your unit owns. Every changed line
must trace back to your row. Remove what your change made unused; report, do not
delete, pre-existing dead code outside your scope.

**Goal-driven verification.** Turn each row into checks that can fail (a test, a
curl status, a computed style, a keyboard pass, a screenshot). Report evidence,
not claims. A reviewer re-runs the checks; a claim without an artifact counts as
not done.

The work queue was a set of TSV files under docs/porting/. components.tsv alone has 189 rows, one per old component, each with an action and a target:

docs/porting/components.tsv @ 5e2aaf22 (lines 1-3)
old_path	importers	action	new_target	phase	note
components/Accordion/index.astro	10	done	components/ui/accordion (MDX name `Accordion`)	P1	shadcn base-ui
components/Accordion/Item.astro	2	done	components/ui/accordion (MDX name `AccordionItem`)	P1

Agents began their work in waves: the design system first, then the blog, then the marketing pages, then the threat intelligence hub. The judge was a Playwright harness that ran against the Astro build and the Next.js build side by side. It checked a manifest of 3,083 URLs for status codes, metadata, head tags, accessibility and screenshots.

Parity did not mean copying bugs. We defined the rule that the old site defines design intent, not behaviour to reproduce. When an agent found an old bug, it fixed it and logged the difference in the file’s deviation log. Repeated agent mistakes went into a “Known mistakes” table at the top of PORTING.md, which every later wave read first. Hand-written font fallback stacks, a 259-line prose stylesheet and hex colours where a token existed all appear there.

The five days produced a site at parity on my machine. It did not go live that week. The Astro site kept serving, and kept receiving blog posts, for three more weeks while we ran both builds and deployed to Vercel. PostHog on the new site went live on November 2.

The Experience of The Flesh

Parity meant the new site matched the old one. It did not (yet) fully mean the new site was fast. And the next weeks went into that by hand and experience of the flesh.

  • Instant record navigations. Threat intelligence links prefetch the record on hover, and record pages read their URL data inside <Suspense> so the App Shell commits at the click. On a slow connection a record now renders in 142 ms instead of 348 ms.

  • Prefetching levels per route. Every dynamic route got an explicit ensureStatic level. The blog uses 'navigation', so an unknown slug is a real 404 and the build rejects uncached work. Threat intelligence records use 'prefetch', so per-link prefetches stay static.

  • First paint on the marketing pages. The hero’s poster is the largest paint, and under cacheComponents the prerendered HTML dropped every preload() hint. A plain <link rel="preload"> rendered in JSX survives, so that is what the hero uses. Real visitors see the largest paint at 1.1 seconds at the 75th percentile, on mobile and desktop alike. Phones no longer download the 1.8 MB hero video at all.

  • Layout shift on campaign pages. A score of 0.50 at the 75th percentile on desktop, down to 0. Fearless Caching has the cause.

  • A different runtime in development. Node’s development server ran out of memory on large posts, so we moved to Bun (why).

  • A site agents can read. llms.txt dynamically generated from the site’s own data, every post answers Accept: text/markdown, and blog posts and threat intelligence records share one JSON-LD graph.

Fearless Caching

Next.js 16 with cacheComponents(opens in new tab) asks one question of every component at build time: can this finish without a real visitor? The answer sorts the build output (or, content) into four kinds. Constant content (for example the header and footer) needs nothing. Shared data that changes now and then, like a blog post or a threat intelligence record, goes in a 'use cache' loader. Anything that depends on the URL is read inside <Suspense>. Anything per visitor, like cookies or the current time, sits inside <Suspense> behind io().

The setting that matters most is ensureStatic(opens in new tab), which decides what a dynamic route does with a slug the build did not list. It is one decision per route, or for an entire layout, and changing it later can be painful.

Which ensureStatic does a dynamic route want?

Must an unknown slug return a real 404?

Answer the questions to see the level.

With that settled, navigations work in stages. A plain <Link> prefetches the route’s App Shell, which is shared by every slug of the route. On a click, Next.js shows the shell at once and streams the rest. Our threat intelligence links prefetch the record itself on hover, so in the common case the click has nothing left to wait for.

Clicking a campaign link, slowed 10×

The 348 ms and 142 ms are measured medians (slow 4G, 4× slower CPU). The shell at 0 ms is how Next.js commits a prefetched App Shell, not a measurement.

t = 408 ms

Astro: a new document

Old page stays until 348 ms, then the whole new page

Header and navconstant
GitHub star countruntime
Breadcrumb: Campaigns / apex-foundationurl
Campaign hero, packages, IOCscached
Footerconstant

Next.js: App Shell, then the record

Prefetched shell at the click (by design), record at 142 ms

Header and navconstant
GitHub star countruntime
Breadcrumb: Campaigns / apex-foundationurl
Campaign hero, packages, IOCscached
Footerconstant
  • constant constant
  • cached cached ('use cache')
  • url URL data (in <Suspense>)
  • runtime runtime (in <Suspense>)

Measured on local production builds, below you can see the difference between performance of the two sites.

Click to visible heading, median of 5

Local production builds. Each run lands on the page, waits for the network to go idle, hovers the link, then clicks. Lower is better.

  • Astro
  • Next.js
TI campaigns list → campaign, unthrottled
Astro:85 ms
Next.js:51 ms
Home → blog index, unthrottled
Astro:135 ms
Next.js:147 ms
TI campaigns list → campaign, slow 4G, 4× CPU
Astro:348 ms
Next.js:142 ms
Home → blog index, slow 4G, 4× CPU
Astro:414 ms
Next.js:295 ms
Show the numbers
CaseAstro (ms)Next.js (ms)
TI campaigns list → campaign, unthrottled8551
Home → blog index, unthrottled135147
TI campaigns list → campaign, slow 4G, 4× CPU348142
Home → blog index, slow 4G, 4× CPU414295

On a fast connection, a full page load from a static Astro site is already quick, and the home to blog hop is a tie. On a slow connection the gap is more prominent. Threat intelligence records are the pages where readers click from list to record to related record, and they render in 142 ms instead of 348 ms.

This is the configuration behind it.

One bug from this model is worth describing, because no test failed on it. Our threat intelligence campaign pages are fully prerendered, yet real visitors on desktop saw a layout shift score of 0.50 at the 75th percentile. React splits a large completed <Suspense> boundary out of the main HTML. The static page painted the short skeleton first, with the footer in view. About 270 ms later React swapped the record in and pushed the footer off screen. The fix was in the layout, not the cache. The skeleton is now at least one screen tall, so the swap moves nothing visible.

Swapping Node for Bun in development

next dev on Node ran out of memory on our larger posts. Every edit to a Server Component grew the heap by about 90 MB and none of it came back. On a 790-line MDX post the development server went from 1,170 MB to 4,872 MB in 17 seconds and crashed.

I traced it to two caches that never evict. React’s development build evaluates a small function per stack frame, with a new URL each time. With source maps enabled, Node keeps the parsed source map of every one of them. Editing a line shifts every position below it, so every edit creates new keys. I filed both halves, nodejs/node#65760(opens in new tab) and next.js#98221(opens in new tab), with a reproduction.

As of the date of writing, Bun does not implement console.createTask(opens in new tab) so React never builds those functions. Bun does not cache generated source maps either. Our scripts run Next.js under Bun (bun -b next dev).

However, it wasn’t all that smooth either. Bun brought its own problem: Bun caches a directory listing before Next.js creates the symlinks for external packages, so pages that used shiki or next-seo failed to resolve them. Bundling those packages through transpilePackages was the workaround.

Profits, Losses and Taxes

The profits are the navigations above and a server we can use. Pages make one third-party request instead of 7 to 9, and that one is a 217-byte pixel. Lighthouse accessibility and best practices score 100 on every page we measured, against 91 to 96 before.

The tax is JavaScript, and Astro ships much less of it.

Bytes transferred on mobile, median of 5 Lighthouse runs

The Astro home total includes the 1.78 MB hero video, which Next.js no longer loads on small screens. Local production builds on one machine (16 cores, 16 GB). Astro at 47a82e1 via astro preview; Next.js 16.4.6 via next start (Bun). Lighthouse 13.6, median of 5 runs. Astro snapshot loads no analytics.

  • Astro
  • Next.js
Home, JavaScript
Astro:137 KiB
Next.js:532 KiB
Home, total
Astro:2,079 KiB
Next.js:800 KiB
Blog post, JavaScript
Astro:139 KiB
Next.js:543 KiB
Blog post, total
Astro:281 KiB
Next.js:829 KiB
TI campaign, JavaScript
Astro:137 KiB
Next.js:536 KiB
TI campaign, total
Astro:334 KiB
Next.js:825 KiB
Show the numbers
CaseAstro (KiB)Next.js (KiB)
Home, JavaScript137532
Home, total2,079800
Blog post, JavaScript139543
Blog post, total281829
TI campaign, JavaScript137536
TI campaign, total334825

The Astro snapshot in this chart loads no analytics, so its 137 KiB is close to the framework’s own weight. Next.js costs about 400 KiB more per page, and we pay it on purpose. It is React and the client router, the machinery behind the instant clicks above. Our production Astro site also carried about 740 KiB of third-party scripts, which is why it felt heavier than this chart suggests.

Lighthouse’s mobile performance score is lower for Next.js on the home page, 80 against 100, because the lab simulation puts the largest paint at 4.8 seconds. Real visitors see 1.1 seconds at the 75th percentile on mobile, measured by PostHog. We report both numbers because both are real. The lab number is a simulation, and readers who run PageSpeed will see it.

The build is about the same. A cold next build takes 44 seconds on my machine, the same as Astro, but peaks at 8.6 GB of memory against Astro’s 3.2 GB. It prerenders 1,028 pages and renders the rest of the threat intelligence pages on first request, where Astro built all 2,995 up front. On CI, a Vercel build takes a median of 97 seconds from push to ready, against 150 seconds on Cloudflare Pages.

The site is also easier for agents to read. Every blog post answers Accept: text/markdown with Markdown, and the HTML version advertises it.

Terminal
$ curl -sI https://safedep.io/blog/litellm-pypi-compromise-credential-stealer-backdoor | grep -i '^link: </blog'
link: </blog/litellm-pypi-compromise-credential-stealer-backdoor.md>; rel="alternate"; type="text/markdown"
$ curl -s -H 'Accept: text/markdown' -o /dev/null -w '%{content_type}\n' https://safedep.io/blog/litellm-pypi-compromise-credential-stealer-backdoor
text/markdown; charset=utf-8

Moving from Astro to Next.js traded one set of trade-offs for another. We pay in JavaScript and build memory. We get instant navigations, a server, and a codebase where the right way is the only way the build accepts. For our site that was the right trade. For a small static blog? Maybe. Maybe not. As with anything, define the requirements first.

  • nextjs
  • astro
  • migration
  • performance
  • agents

Author

Share

Ship Code.Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.