{"slug": "the-rules-that-moved-safedep-io-from-astro-to-next-js-16", "title": "The Rules That Moved safedep.io from Astro to Next.js 16", "summary": "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.", "body_md": "# The Rules That Moved safedep.io from Astro to Next.js 16\n\nOn this page\n\nWe moved safedep.io, about 3,000 pages across the marketing site, the blog and the\n[threat intelligence hub](https://safedep.io/ti), from Astro to Next.js 16. Before any agent touched the code, I wrote\ndown what correct code looks like on this site\nand set the build to reject everything else. With that in place, coding agents wrote most of the 536\ncommits and reached parity in five days. That groundwork, and the weeks of hand-tuning after parity,\nare what this post is about.\n\nThe 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.\n\nTwo kinds of rules carried the project. The first are rendering rules for Next.js 16:\n\n1. **Cache the data, not the component.** Put`'use cache'` on the function that\nloads a record, never on a page or a component that holds a`<Suspense>` .\n2. **Read `params` inside `<Suspense>`** , even when` generateStaticParams` lists every value.\n3. **Any uncached `await` makes that part of the page per-request.** Next.js doesn’t have a time budget for an asynchronous/uncached call.\n4. **Pick one `ensureStatic` level per dynamic route on day one** ([how to choose](#fearless-caching) ).\n5. **Turn on `cacheComponents`, `partialPrefetching` and `cachedNavigations`** for instant\nnavigations between prerendered pages.\n6. **Prefetch on intent** (hover, focus, touch) when a page holds runtime data.\n\nThe second kind are code rules, and they matter more. Each thing has one right\nway to be written.  Lint and CI enforce it, so neither people nor agents can\nship the second way ([the rules](#one-right-way-to-do-things)).\n\n## The limits of Astro, or outgrowing your tech stack\n\nThe old site was an AstroWind fork deployed to Cloudflare Pages: 202 `.astro`\nfiles and about 19,000 lines of templates, rendered to static HTML at build\ntime. For a content site that is a reasonable design, and Astro ships almost no\nJavaScript by default. Our problems came from what the site had grown into.\n\nInteractive 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:\n\nSure, 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.\n\nThe second limit was **the server**. The output was static, so anything that\nneeded a server was off the table: server-side tests, server-rendered pages,\nfeature flags, incremental regeneration. We could not mix prerendered pages with\nserver-rendered ones. Astro has SSR adapters, but with one we would still be\nwriting request handlers. If I am writing request handlers anyway, Go with templ\nwould give us a smaller binary than either framework.\n\nThe third limit was the third-party scripts layered on top of the site. Every\npage loaded about 740 KiB of them: Google Tag Manager, `gtag.js` twice, and\nPostHog from PostHog’s own servers, where any ad blocker could stop it. Until\nOctober 3 the snippet also sampled only 75% of visits.\n\n## But why Next.js?\n\nThe honest answer starts with our requirements. Our [app website `app.safedep.io`(opens in new tab)](https://app.safedep.io)\nis a Next.js app, and I have run Next.js in production for years. What I wanted\nfrom the new site was specific. Navigations between pages should feel instant\nwithout shipping a single-page app, some content should render on the server,\nand feature flags and ISR should be one decision away. The table records what\nwe weighed.\n\n| Option | Why we passed on it | \n|---|---|\n| Astro with an SSR adapter | We would write request handlers, and the DOM-script problem stays. | \n| Go and templ | Small and fast to serve, but the site needs client-side interactivity, so React comes back anyway. | \n| TanStack Start | A serious option. We had Next.js experience and a Next.js product to share code with. | \n| vinext, OpenNext | We wanted Next.js itself, because we planned to run its experimental features in production. | \n| Next.js 16 | Prerendering plus instant navigations, server code, the stack our product already uses. | \n\nNext.js costs more JavaScript than Astro, and the [last section](#profits-losses-and-taxes) puts a\nnumber on it. We bought instant navigations and a server with it, which is what we came for.\n\n## One right way to do things\n\nThe execution of the laws is more important than the making of them.\n\nIt’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.\n\nSo 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.\n\n- **No `useEffect`.** I have seen people and agents write effect bodies that nobody could follow a\nmonth later. State gets copied from props, effects trigger other effects, and event logic hides in\ndependency arrays. So the hook is banned outright, along with`useLayoutEffect` and`useInsertionEffect` , through`no-restricted-imports` . The lint message tells you what to do\ninstead:`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.\n- **Complexity has a ceiling.** A component’s cyclomatic complexity stops at 15, callbacks nest at\nmost three deep, and a function takes at most four parameters, after which you pass one named\nobject. I brought these thresholds over from the golangci-lint setup I introduced in our Go\nbackend (`cyclop` ,`gocognit` ,`nestif` ). Go codebases rot the same way without them, and the same\nlimit of 15 holds there.\n- **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\nentry point, which sends events to a first-party`/ingest` proxy, so ad blockers no longer decide\nwhat we measure. Shout out to[`depguard`(opens in new tab)](https://golangci-lint.run/docs/linters/configuration/#depguard) for the inspiration.\n- **Style rules are ast-grep rules.** A hex colour or a`-[13px]` class anywhere in a class string\nfails the pre-commit hook. A hand-written`target=\"_blank\"` outside the branded link components\nfails too.\n- **Links are typed.**`typedRoutes` turns a link to a page that does not exist into a type error,\nand a test checks every internal link in every post.\n- **Dead code is found.**`knip` reports unused files, exports and dependencies.\n- **The build checks the rendering.** Blog routes use`ensureStatic = 'navigation'` , so any uncached\nwork on them fails`next build` .\n\nA 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.\n\nThe 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.\n\n## Porting 3,000 pages in 5 days\n\nNow with rules in hand, the port itself became mechanical. The method came from\nJarred Sumner’s account of [rewriting Bun in Rust(opens in new tab)](https://bun.com/blog/bun-in-rust).\nWrite the rules down before anyone ports anything. Give agents a queue of small\nunits then let tests decide what is done.\n\nThe rules live in `docs/PORTING.md`. 946 lines of unambiguous instructions, of\nwhich here is an excerpt:\n\nThe work queue was a set of TSV files under `docs/porting/`. `components.tsv`\nalone has 189 rows, one per old component, each with an action and a target:\n\nAgents began their work in waves: the design system first, then the blog, then\nthe marketing pages, then the [threat intelligence hub](https://safedep.io/ti). The judge was a\nPlaywright harness that ran against the Astro build and the Next.js build side\nby side. It checked a manifest of 3,083 URLs for status codes, metadata, head\ntags, accessibility and screenshots.\n\nParity did not mean copying bugs. We defined the rule that the old site defines\ndesign intent, not behaviour to reproduce. When an agent found an old bug, it\nfixed it and logged the difference in the file’s deviation log. Repeated agent\nmistakes went into a “Known mistakes” table at the top of `PORTING.md`, which\nevery later wave read first. Hand-written font fallback stacks, a 259-line prose\nstylesheet and hex colours where a token existed all appear there.\n\nThe 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.\n\n## The Experience of The Flesh\n\nParity meant the new site matched the old one. It did not *(yet)* fully mean the\nnew site was fast. And the next weeks went into that by hand and experience of\nthe flesh.\n\n- \n**Instant record navigations.** Threat intelligence links prefetch the record\non hover, and record pages read their URL data inside`<Suspense>` so the App\nShell commits at the click. On a slow connection a record now renders in 142 ms\ninstead of 348 ms.\n- \n**Prefetching levels per route.** Every dynamic route got an explicit`ensureStatic` level.  The blog uses`'navigation'` , so an unknown slug is a\nreal 404 and the build rejects uncached work.  Threat intelligence records use`'prefetch'` , so per-link prefetches stay static.\n- \n**First paint on the marketing pages.** The hero’s poster is the largest\npaint, and under`cacheComponents` the prerendered HTML dropped every`preload()` hint. A plain`<link rel=\"preload\">` rendered in JSX survives, so\nthat is what the hero uses. Real visitors see the largest paint at 1.1 seconds\nat the 75th percentile, on mobile and desktop alike. Phones no longer download\nthe 1.8 MB hero video at all.\n- \n**Layout shift on campaign pages.** A score of 0.50 at the 75th percentile on\ndesktop, down to 0.[Fearless Caching](#fearless-caching) has the cause.\n- \n**A different runtime in development.** Node’s development server ran out of\nmemory on large posts, so we moved to Bun\n([why](#swapping-node-for-bun-in-development) ).\n- \n**A site agents can read.**`llms.txt` dynamically generated from the site’s\nown data, every post answers`Accept: text/markdown` , and blog posts and threat\nintelligence records share one JSON-LD graph.\n\n## Fearless Caching\n\nNext.js 16 with [`cacheComponents`(opens in new tab)](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents)\nasks one question of every component at build time: can this finish without a\nreal visitor? The answer sorts the build output (or, content) into four kinds.\nConstant content (for example the header and footer) needs nothing. Shared data that changes\nnow and then, like a blog post or a threat intelligence record, goes in a `'use cache'`\nloader. Anything that depends on the URL is read inside `<Suspense>`. Anything\nper visitor, like cookies or the current time, sits inside `<Suspense>` behind\n`io()`.\n\nThe setting that matters most is\n[`ensureStatic`(opens in new tab)](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config/ensureStatic),\nwhich decides what a dynamic route does with a slug the build did not list. It\nis one decision per route, or for an entire layout, and changing it later can be\npainful.\n\nWhich `ensureStatic` does a dynamic route want?\n\nAnswer the questions to see the level.\n\nWith that settled, navigations work in stages. A plain `<Link>` prefetches the\nroute’s App Shell, which is shared by every slug of the route. On a click,\nNext.js shows the shell at once and streams the rest. Our threat intelligence\nlinks prefetch the record itself on hover, so in the common case the click has\nnothing left to wait for.\n\nClicking a campaign link, slowed 10×\n\nThe 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.\n\nt = 408 ms\n\nAstro: a new document\n\nOld page stays until 348 ms, then the whole new page\n\nNext.js: App Shell, then the record\n\nPrefetched shell at the click (by design), record at 142 ms\n\n- constant constant\n- cached cached ('use cache')\n- url URL data (in <Suspense>)\n- runtime runtime (in <Suspense>)\n\nMeasured on local production builds, below you can see the difference between performance of the two sites.\n\nLocal production builds. Each run lands on the page, waits for the network to go idle, hovers the link, then clicks. Lower is better.\n\n- Astro\n- Next.js\n\n- TI campaigns list → campaign, unthrottled\n- Astro:85 ms\n- Next.js:51 ms\n- Home → blog index, unthrottled\n- Astro:135 ms\n- Next.js:147 ms\n- TI campaigns list → campaign, slow 4G, 4× CPU\n- Astro:348 ms\n- Next.js:142 ms\n- Home → blog index, slow 4G, 4× CPU\n- Astro:414 ms\n- Next.js:295 ms\n\n## Show the numbers\n\n| Case | Astro (ms) | Next.js (ms) | \n|---|---|---|\n| TI campaigns list → campaign, unthrottled | 85 | 51 | \n| Home → blog index, unthrottled | 135 | 147 | \n| TI campaigns list → campaign, slow 4G, 4× CPU | 348 | 142 | \n| Home → blog index, slow 4G, 4× CPU | 414 | 295 | \n\nOn 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.\n\nThis is the configuration behind it.\n\nOne bug from this model is worth describing, because no test failed on it. Our\nthreat intelligence campaign pages are fully prerendered, yet real visitors on\ndesktop saw a layout shift score of 0.50 at the 75th percentile. React splits a\nlarge completed `<Suspense>` boundary out of the main HTML. The static page\npainted the short skeleton first, with the footer in view. About 270 ms later\nReact swapped the record in and pushed the footer off screen. The fix was in the\nlayout, not the cache. The skeleton is now at least one screen tall, so the swap\nmoves nothing visible.\n\n## Swapping Node for Bun in development\n\n`next dev` on Node ran out of memory on our larger posts. Every edit to a Server\nComponent grew the heap by about 90 MB and none of it came back. On a 790-line\nMDX post the development server went from 1,170 MB to 4,872 MB in 17 seconds and\ncrashed.\n\nI traced it to two caches that never evict. React’s development build evaluates\na small function per stack frame, with a new URL each time. With source maps\nenabled, Node keeps the parsed source map of every one of them. Editing a line\nshifts every position below it, so every edit creates new keys. I filed both\nhalves, [nodejs/node#65760(opens in new tab)](https://github.com/nodejs/node/issues/65760) and\n[next.js#98221(opens in new tab)](https://github.com/vercel/next.js/issues/98221), with a\nreproduction.\n\nAs of the date of writing, [Bun does not implement `console.createTask`(opens in new tab)](https://github.com/oven-sh/bun/issues/40795)\nso React never builds those functions. Bun does not cache generated source maps\neither. Our scripts run Next.js under Bun (`bun -b next dev`).\n\nHowever, it wasn’t all that smooth either. Bun brought its own problem: Bun\ncaches a directory listing before Next.js creates the symlinks for external\npackages, so pages that used `shiki` or `next-seo` failed to resolve them.\nBundling those packages through `transpilePackages` was the workaround.\n\n## Profits, Losses and Taxes\n\nThe 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.\n\nThe tax is JavaScript, and Astro ships much less of it.\n\nThe 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.\n\n- Astro\n- Next.js\n\n- Home, JavaScript\n- Astro:137 KiB\n- Next.js:532 KiB\n- Home, total\n- Astro:2,079 KiB\n- Next.js:800 KiB\n- Blog post, JavaScript\n- Astro:139 KiB\n- Next.js:543 KiB\n- Blog post, total\n- Astro:281 KiB\n- Next.js:829 KiB\n- TI campaign, JavaScript\n- Astro:137 KiB\n- Next.js:536 KiB\n- TI campaign, total\n- Astro:334 KiB\n- Next.js:825 KiB\n\n## Show the numbers\n\n| Case | Astro (KiB) | Next.js (KiB) | \n|---|---|---|\n| Home, JavaScript | 137 | 532 | \n| Home, total | 2,079 | 800 | \n| Blog post, JavaScript | 139 | 543 | \n| Blog post, total | 281 | 829 | \n| TI campaign, JavaScript | 137 | 536 | \n| TI campaign, total | 334 | 825 | \n\nThe 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.\n\nLighthouse’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.\n\nThe build is about the same. A cold `next build` takes 44 seconds on my machine,\nthe same as Astro, but peaks at 8.6 GB of memory against Astro’s 3.2 GB. It\nprerenders 1,028 pages and renders the rest of the threat intelligence pages on\nfirst request, where Astro built all 2,995 up front. On CI, a Vercel build takes\na median of 97 seconds from push to ready, against 150 seconds on Cloudflare\nPages.\n\nThe site is also easier for agents to read. Every blog post answers `Accept: text/markdown` with Markdown, and the HTML version advertises it.\n\nMoving 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.\n\n- nextjs\n- astro\n- migration\n- performance\n- agents\n\nAuthor\n\nsafedep.io", "url": "https://wpnews.pro/news/the-rules-that-moved-safedep-io-from-astro-to-next-js-16", "canonical_source": "https://safedep.io/blog/safedep-io-astro-to-nextjs-migration", "published_at": "2026-11-13 00:00:00+00:00", "updated_at": "2026-11-13 06:49:56.639840+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["SafeDep", "safedep.io", "Next.js 16", "Astro", "AstroWind", "Cloudflare Pages", "PostHog", "Google Tag Manager"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-rules-that-moved-safedep-io-from-astro-to-next-js-16", "markdown": "https://wpnews.pro/news/the-rules-that-moved-safedep-io-from-astro-to-next-js-16.md", "text": "https://wpnews.pro/news/the-rules-that-moved-safedep-io-from-astro-to-next-js-16.txt", "jsonld": "https://wpnews.pro/news/the-rules-that-moved-safedep-io-from-astro-to-next-js-16.jsonld"}}