---
title: "The Rules That Moved safedep.io from Astro to Next.js 16"
description: "Lint before code, useEffect banned, complexity capped at 15, clicks 2.4× faster, and an interactive guide to choosing your Next.js caching setup."
url: "https://safedep.io/blog/safedep-io-astro-to-nextjs-migration"
author: "Arunanshu Biswas"
published: "2026-11-13"
---

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

We moved safedep.io, about 3,000 pages across the marketing site, the blog and the
[threat intelligence hub](https://safedep.io/ti), 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](#fearless-caching)).
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](#one-right-way-to-do-things)).

Commits per day during the port (536 of 691):

| Day (UTC) | Commits |
|---|---:|
| 2026-10-06 | 78 |
| 2026-10-07 | 124 |
| 2026-10-08 | 137 |
| 2026-10-09 | 119 |
| 2026-10-10 | 78 |

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

```ts title="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`](https://app.safedep.io)
 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](#profits-losses-and-taxes) 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`](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-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](https://bun.com/blog/bun-in-rust).
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:

```md title="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:

```tsv title="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](https://safedep.io/ti). 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](#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](#swapping-node-for-bun-in-development)).

- **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`](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) 
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`](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/ensureStatic),
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.

- Unknown slug must be a real 404: `ensureStatic = 'navigation'` (our blog routes).
- Otherwise, with `prefetch={true}` links: `ensureStatic = 'prefetch'` (our TI records).
- Otherwise: `ensureStatic = 'shell'` or unset.

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.

Interactive replay. Measured on a slow 4G profile: the Astro site shows the new page after 348 ms, Next.js shows the record at 142 ms. Next.js also commits the prefetched App Shell at the click; that is how App Shell navigation works, not a measured time.

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

Click to visible heading, median of 5:

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

**Our next.config.ts flags**

```ts title="next.config.ts @ 5e2aaf22 (excerpt)"
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  reactCompiler: true,
  typedRoutes: true,

  experimental: {
    // ux improvement
    cachedNavigations: true,

    // build output improvement
    turbopackCrossModuleConstants: true,
    turbopackChunking: { generateComponentChunks: true },
    serverMinification: true,
    turbopackCjsTreeShaking: true,
    turbopackCjsScopeHoisting: true,
    turbopackPluginRuntimeStrategy: 'workerThreads',

    // DX improvement
    requestInsights: true,
    turbopackGc: true,
    serverComponentsHmrCancellation: true,
    turbopackLazyDynamicImports: true,
    memoryBasedWorkersCount: true,
    workerThreads: true,

    // perf
    turbopackRustReactCompiler: true,
  },
}
```

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

As of the date of writing, [Bun does not implement `console.createTask`](https://github.com/oven-sh/bun/issues/40795)
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:

| Case | Astro (KiB) | Next.js (KiB) |
|---|---:|---:|
| Home, JavaScript | 137 | 532 |
| Home, total | 2079 | 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.

```console
$ 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.
