cd /news/developer-tools/the-rules-that-moved-safedep-io-from… · home › topics › developer-tools › article
[ARTICLE · art-168835] src=safedep.io ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

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

SafeDep moved safedep.io — roughly 3,000 pages spanning its marketing site, blog and threat intelligence hub — from Astro to Next.js 16, with coding agents writing most of the 536 commits and reaching parity in five days after the author first codified site-wide code rules that the build enforces. The rebuilt site clicks between pages up to 2.4 times faster on a slow connection, loads no third-party scripts (down from about 740 KiB per page), scores 100 on accessibility and can run code on the server, though it ships more JavaScript than the Astro site did. The previous AstroWind fork on Cloudflare Pages comprised 202 .astro files and about 19,000 lines of templates, and its PostHog snippet sampled only 75% of visits until October 3.

by read16 min views1 publishedNov 13, 2026
The Rules That Moved safedep.io from Astro to Next.js 16
Image: Safedep (auto-discovered)

On this page

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).

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:

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.

Option Why we passed on it
Astro with an SSR adapter We would write request handlers, and the DOM-script problem stays.
Go and templ Small and fast to serve, but the site needs client-side interactivity, so React comes back anyway.
TanStack Start A serious option. We had Next.js experience and a Next.js product to share code with.
vinext, OpenNext We wanted Next.js itself, because we planned to run its experimental features in production.
Next.js 16 Prerendering plus instant navigations, server code, the stack our product already uses.

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 withuseLayoutEffect anduseInsertionEffect , throughno-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 fromeslint-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 importposthog-js , throughno-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)](https://golangci-lint.run/docs/linters/configuration/#depguard) 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-writtentarget="_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 useensureStatic = 'navigation' , so any uncached work on them failsnext 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:

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:

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.

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 answersAccept: 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' . 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?

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

Next.js: App Shell, then the record

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

  • 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.

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 #

| Case | Astro (ms) | Next.js (ms) | 
|---|---|---|

| TI campaigns list → campaign, unthrottled | 85 | 51 | | Home → blog index, unthrottled | 135 | 147 | | TI campaigns list → campaign, slow 4G, 4× CPU | 348 | 142 | | Home → blog index, slow 4G, 4× CPU | 414 | 295 |

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)](https://github.com/nodejs/node/issues/65760) and
[next.js#98221(opens in new tab)](https://github.com/vercel/next.js/issues/98221), 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.

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 #

| Case | Astro (KiB) | Next.js (KiB) | 
|---|---|---|

| Home, JavaScript | 137 | 532 | | Home, total | 2,079 | 800 | | Blog post, JavaScript | 139 | 543 | | Blog post, total | 281 | 829 | | TI campaign, JavaScript | 137 | 536 | | TI campaign, total | 334 | 825 |

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.

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

safedep.io

── more in #developer-tools 4 stories · sorted by recency
── more on @safedep 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-rules-that-moved…] indexed:0 read:16min 2026-11-13 · —