We Migrated Static Signal from Next.js to Astro. Here's What Actually Changed.
A week ago we published When Astro Beats Next.js for Content Sites. The argument was that most of what gets built on Next.js is content, and Astro fits content better — smaller bundles, faster builds, fewer dependencies, less ceremony.
Then we did the obvious thing: we migrated Static Signal — the blog you are reading right now — from Next.js to Astro. The whole site. One PR. Half a working day.
If you are reading this on Render-hosted production, you are looking at the Astro version. The article you are inside of was built by Shiki tokenizing fenced code blocks at build time, not by prism-react-renderer running in your browser. The categories page you can click to from the header was rendered as flat HTML at build time, not hydrated from a React tree. The node_modules folder that produced this page has 395 packages in it, down from 736 the day before.
This essay is the retrospective. The real numbers from a real production migration of an active blog, not a benchmark. The pieces of the previous argument that survived contact with reality, the parts that bent, and the gotchas no one warns you about — including the one that almost made me give up at midnight on a Monday.
The Numbers
The point of the previous post was that the Astro-versus-Next.js choice has a measurable cost. So before any narrative, here is the measurable cost as it actually played out on this site, comparing the last Next.js production deploy on main against the first Astro production deploy on the same main after merge.
| Metric | Next.js (before) | Astro (after) | Δ |
|---|---|---|---|
node_modules package count | 736 | 395 | −46% |
Direct deps in package.json | 31 | 24 | −23% |
| Cold build time (local) | ~30s | ~3s | −90% |
| Cold build time (Render) | ~75s | ~15s | −80% |
| Pages built | 60 | 60 | — |
Shipped JS (_astro/ total) | ~1.8MB | 284KB | −84% |
| First-load JS, post page | ~187KB | ~14KB | ~13× smaller |
| Lighthouse Performance (mobile) | 87 | 100 | +13 |
The 100 deserves a moment. That is not a number I have personally hit on a real production marketing site in years. It is what you get when you ship roughly nothing to the browser and let the CDN do the rest. The post page that contains the article you are reading right now ships 14KB of JavaScript, total, and that 14KB is the TextSizer button group at the top of the article body — the only interactive element on the page that needs JS to function.
Not every metric improved. The build artifact on disk got slightly larger because Astro emits a CSS file per layout and a manifest, while Next.js bundled CSS more aggressively. Three or four kilobytes. Within margin of error.
The thing that did not change at all: the site looks identical. Same dark steampunk theme, same purple accents, same fonts, same hero images, same code-block style. If you bookmarked the previous version, the URLs are unchanged. The redirects we already had still work. The RSS feed at /feed.xml still produces the same Atom XML, byte-for-byte for entries that were already published.
What the Migration Actually Touched
Reading back the diff: 63 files changed, 3,318 insertions, 9,622 deletions. A net deletion of 6,304 lines of code for the same site. That number is the single best summary of the difference between the two frameworks for this use case.
The shape of the change:
File-system reorganization. Next.js App Router puts routes in src/app/<path>/page.tsx. Astro puts them in src/pages/<path>.astro. Every page got a new path and a new extension. The route shape stayed the same — /, /about/, /posts/, /posts/[slug]/, /categories/, /categories/[slug]/, /posts/page/[n]/ — but the implementation moved from JSX-with-React-conventions to Astro components.
Layouts went from React to Astro. The old app/layout.tsx had a React Provider tree, a <Script strategy="beforeInteractive"> for the no-flash dark-mode init, and a <Metadata> export driving the OG tags. The new BaseLayout.astro has an inline <script is:inline> for the theme init (no React provider needed), props for title / description / canonical / ogImage, and slot-based content composition. The provider tree disappeared entirely because it was load-bearing for exactly nothing — the dark theme is set by a six-line inline script and CSS variables do the rest.
Components split into two buckets. Components that needed to be interactive — the mobile menu in the Header, the Pagefind search dialog, the text-size toggle on article pages, the IntersectionObserver-driven scroll-reveal — stayed as React components but became Astro islands: imported into .astro files and tagged with client:load or client:idle. Components that were marked 'use client' in the Next.js codebase but never actually needed to be (the Pagination component using useRouter for navigation, the Card, the PostHero, the Footer, the Logo) became plain .astro files that render to HTML at build time and ship zero JavaScript.
The line between those two buckets is the interesting part of the migration. The Next.js codebase had 'use client' on 15 files. The Astro codebase has 5 real islands. The other 10 were defensively marked client because Next.js’s React Server Components boundary plumbing made things easier to reason about that way. None of them actually needed React on the client.
Content layer rewrote itself for free. The old code used gray-matter to parse frontmatter from .md files in content/posts/, wrapped in a hand-rolled lib/content.ts that exposed getAllPosts, getPostBySlug, etc. Astro’s content collections cover the same surface natively, with a Zod schema for type-safe frontmatter, MDX support without a separate package, and a render() API that returns a typed <Content /> component. The new lib/posts.ts is half the size of the old lib/content.ts and the types are stronger.
Pagefind didn’t move. It already operates on built HTML in a publish directory. The build script changed from next build && pagefind --site out to astro build && pagefind --site dist. That was the entire Pagefind migration.
What Was Easier Than Expected
A few things were genuinely pleasant.
MDX with custom components. Exactly one post in the archive uses real MDX components (the React-hero-sections piece with five interactive previews). I renamed it from .md to .mdx, kept the file otherwise identical, and passed the HeroPreview component to <Content components={{ HeroPreview }} /> in the post template. The MDX renders, each preview becomes an Astro island, and the only client JS is the React runtime needed to hydrate those five small components — on that one post, not on every post in the site.
Shiki at build time. The previous post on this site argued for replacing runtime tokenizers with build-time ones. Astro’s MDX integration ships Shiki by default. There was no migration step; I deleted prism-react-renderer from package.json, removed the CodeBlock React component, and Astro started tokenizing fenced code blocks during the build. Every ```typescript block in the entire archive — including the one you are reading right now — got tokenized at build time with the github-dark theme baked into the HTML. Net effect on a typical post page: minus 50-something kilobytes of JavaScript, plus a few kilobytes of pre-tokenized HTML.
The dependency cleanup. The deletion list ended up being long: next, next-mdx-remote, prism-react-renderer, two @radix-ui packages, class-variance-authority, cross-env, gray-matter, eslint-config-next, @tailwindcss/postcss, postcss, tailwind-merge, clsx. Some of these were obviously load-bearing on the Next.js stack and don’t apply to Astro. Others (the @radix-ui packages, class-variance-authority, tailwind-merge, clsx) were imported by exactly one unused shadcn UI component and went away the moment we deleted that component. The audit I described in The Dependency-Count Audit writes itself when you change frameworks — you cannot avoid looking at every dep.
What Was Harder Than Expected
Render’s Blueprint sync. The site is hosted on Render and configured via render.yaml (an Infrastructure-as-Code Blueprint). Render reads service configuration from main, not from the PR branch. That means a PR that changes staticPublishPath from ./out to ./dist (which the Astro migration did) can build successfully on a Render preview environment and then fail at publish time because Render is looking for the directory main told it to look in.
The fix was to land a transitional commit on main that did two things at once: change the publish path to ./dist and update the Next.js build script to mv out dist after next build, so production kept working but used the new directory layout. Then the Astro PR’s preview deployment finally worked.
This is a real Render footgun for any in-place framework migration where the output directory changes. The Blueprint architecture wants you to think of YAML as the source of truth, but the time-machine semantics (“YAML on main describes the production service”) create a chicken-and-egg problem for previews. There is no previews.staticPublishPath override; you have to use the same path for both, or sequence the changes carefully.
The SPA-fallback rewrite no one set on purpose. This is the gotcha that almost cost the migration. Somewhere in the history of the service, someone — possibly a templated Render setup from 2024, possibly an early bug fix — added a /** → /index.html rewrite rule to the service in the Render dashboard. That rule was not in render.yaml and not visible to anyone who only read the codebase. It silently sat there as the highest-priority rewrite on the service.
The effect: every URL that did not exactly match an output file in the publish directory got rewritten to the home page. /about (no trailing slash) returned the home page. /categories returned the home page. /something-random returned the home page. Render’s “if the resource exists at the path, no rewrites apply” semantics meant /about/ (with the slash, matching the index.html at dist/about/index.html) worked fine — which is why nobody noticed.
This bug existed on the Next.js production for an unknown number of months. The Astro migration surfaced it because I was clicking around verifying every page and noticed the missing slash on the nav links. The fix was a single click in the Render dashboard to delete the rule. After that, the redirect routes in render.yaml did exactly what they were supposed to do, the custom 404 page started rendering correctly, and the trailing-slash redirects fell into line.
The lesson: dashboard-configured infrastructure and YAML-configured infrastructure are two different sources of truth. Auditing the dashboard against the Blueprint is now part of any migration checklist on Render.
The trailing-slash decision tax. Astro has three formats for rendered URLs: trailing slash always, never, or ignore. With 'always' and build.format: 'directory', the output is dist/about/index.html and the canonical URL is /about/. With 'never' and build.format: 'file', the output is dist/about.html and the canonical URL is /about. Both work; both produce reasonable URLs.
But on most static hosts, exactly one of these forms requires zero server-side configuration and the other requires explicit redirects. Render’s static-site server, like Cloudflare Pages and Vercel, prefers the directory form — but only if you also handle the no-slash incoming requests with explicit rewrites or redirects (in render.yaml routes). We landed on directory form with five redirect rules: /about → /about/, /posts/* → /posts/:splat/, etc. The previous Next.js production had the same trailing-slash setting and never noticed the same latent bug.
The migration is the moment to pick one form, write down why, and commit. Choosing later is harder.
What Astro Forces You to Think About That Next.js Hides
In Next.js, the easy path is to mark a component 'use client' the second you reach for a hook or an event handler. The framework hides the boundary; the bundle quietly grows; the team moves on. The defaults are forgiving.
In Astro, the easy path is the opposite. A component renders to HTML at build time by default. If you want it to be interactive, you have to opt in — at the import site, with a client:* directive. That extra step is small, but it forces a conversation: “Why does this component need to ship JavaScript? What state does it actually have on the client?”
For most things in a content site, the honest answer is: it doesn’t. The card on the recent-posts grid doesn’t need React; it needs an <a> and an <img>. The post hero doesn’t need React; it needs an <h1>, a <time>, and an <img>. The pagination doesn’t need React; it needs five <a> tags. We were shipping React for those because the Next.js architecture made shipping React the path of least resistance, not because the components needed it.
Astro inverts the resistance. The path of least resistance is HTML. You ship JavaScript when there is a specific reason to. Across this site that meant: the mobile menu, the Pagefind search dialog, the text-size toggle, the scroll-reveal animation system, and the live React heroes in one specific MDX post. Five islands, total. The other 33-ish components became HTML.
This is not a moral position; it is a mechanical one. Next.js can ship just as little JavaScript as Astro if you are religiously disciplined about Server Components. Most teams aren’t, because the defaults pull in the other direction. Astro’s defaults pull toward “no JS unless asked.” That is the entire architectural difference, and it shows up as a fourteen-times smaller per-page bundle on this specific site.
What I Would Do Differently
Three things, in retrospect:
Sequence the publish-path change before the framework change. Land staticPublishPath: ./dist and the build-script rename on main as a no-op preparatory commit. Then open the Astro PR. The chicken-and-egg Render-preview issue goes away if you treat the publish directory as a separate concern from the framework migration.
Audit the Render dashboard before opening the PR. Specifically check the “Redirects and Rewrites” tab for any rules that aren’t in render.yaml. The /** → /index.html rule cost me an hour of debugging and a real bug-fix commit on main to delete it. Knowing about it on day zero would have saved that.
Move the content collections schema earlier in the migration. I left content-collections wiring for the back half of the work and ended up duplicating effort on the post-detail page. Defining the Zod schema for posts on day one would have given me typed post.data access throughout the rest of the migration and would have caught two frontmatter typos earlier than they were caught.
None of these were show-stoppers. The whole migration was still half a working day. But the next time we do this — and there will be a next time, because the rest of the content-site portfolio is also on Next.js for now — these are the three things we will do differently.
Walking The Talk
The previous post on this site argued that Next.js is the wrong tool for most content sites and that Astro is the right one. The follow-up question, asked by every reader and asked by the team that runs the blog: if you believe that, why are you not running it on Astro?
Now we are. The Performance score is 100. The build is 3 seconds. The dependency count is half of what it was. Internal links work. Search works. RSS validates. The dark-mode flash is gone. The custom 404 page renders. The site is faster than it has ever been, and it is also smaller, simpler, and easier to reason about than the version it replaced.
The next time someone asks whether the choice between Next.js and Astro is real or marketing, the answer is up at the top of this page. Lighthouse 100, 14KB of JS, three-second build, three-hundred-and-ninety-five packages.
The thesis survives contact with reality.