Next.js dev server cold start hit 14 seconds on a static portfolio, that was the breaking point.
Bundle dropped from 187 KB to 9 KB across 3 sites, Lighthouse went 78 to 100.
Server islands and the content layer in Astro 5 made the migration genuinely simple.
Next.js still wins for dashboards, RSC streaming, and edge middleware on real apps.
Marketing sites belong on Astro, app surfaces stay on Next.js, pick by use case not loyalty.
I spent a weekend moving three marketing sites from Next.js 15 to Astro 5. A portfolio, a product landing page, and a blog. No team, no migration committee, just me, a fresh branch, and a stopwatch. The numbers were the kind of numbers that make you question why you waited so long. Bundle size dropped by more than 95 percent on every site. Lighthouse jumped from middling to perfect. Build times got cut in half. This post is the honest version of what happened, what Astro 5 does well, and where Next.js still earns its keep.
Why I migrated
The breaking point was a Tuesday morning. My portfolio dev server took 14 seconds to cold start. The site has 11 pages, three of which are MDX, and zero interactive widgets beyond a theme toggle. Next.js was shipping a React runtime to render markdown. That is the moment I stopped pretending.
I had been on Next.js since version 12. App Router was fine. RSC was fine. But every marketing site I built kept needing the same fight: turn off prefetch, mark everything as static, fight the React bundle, then watch a 9 KB hero ship as 180 KB of hydrated nonsense. The framework wanted me to build an app. I was building a brochure.
The math was simple. Three sites. Zero need for client state. Maybe one form on each. Astro was built for exactly this shape and Astro 5 had finally landed the features that closed the last gaps. Server islands meant I could keep dynamic bits where I wanted them. The content layer meant my MDX pipeline got faster instead of slower. So I blocked off Saturday and Sunday, branched all three repos, and started porting.
The 3 projects and the data
Project one was my personal portfolio. 11 pages, MDX case studies, a contact form, a small image gallery. Project two was a landing page for a paid template I sell. Single page, long, lots of sections, two embedded videos. Project three was a developer-focused blog. 47 posts at the time, MDX, syntax highlighting, RSS feed, sitemap.
I tracked five things on each: total JS shipped to the homepage, Lighthouse performance score on mobile, production build time, dev server cold start, and total file count in the repo. Same hardware, same network, both versions running locally and deployed to Vercel for the Lighthouse pass.
| Project | Bundle (Next) | Bundle (Astro) | Lighthouse (Next) | Lighthouse (Astro) | Build (Next) | Build (Astro) |
|---|---|---|---|---|---|---|
| Portfolio | 187 KB | 9 KB | 78 | 100 | 38s | 14s |
| Landing page | 142 KB | 6 KB | 84 | 100 | 22s | 9s |
| Dev blog | 211 KB | 12 KB | 71 | 99 | 1m 47s | 41s |
Dev server cold start went from 14s, 9s, and 22s on Next to 0.6s, 0.4s, and 1.1s on Astro. File count dropped on average by 18 percent, mostly because I deleted a pile of 'use client' boilerplate, route handlers I no longer needed, and a custom MDX loader that the content layer replaced.
The bundle wins are not just about the framework. They are about default behaviour. Next.js asks me to opt out of JavaScript. Astro asks me to opt in. For a marketing site, that flip changes everything.
A typical Astro page looked like this after the port:
---
import Layout from '../layouts/Base.astro'
import Hero from '../components/Hero.astro'
import { getCollection } from 'astro:content'
const projects = await getCollection('projects')
---
{projects.map((p) => (
[{p.data.title}]({`/work/${p.slug}`})
))}
Zero JavaScript shipped. The same page in Next.js, even as a Server Component, still booted a small React runtime for the link prefetch behaviour and the layout boundaries:
// app/work/page.tsx
import Link from 'next/link'
import { getProjects } from '@/lib/content'
export default async function Work() {
const projects = await getProjects()
return (
<>
{projects.map((p) => (
{p.title}
))}
>
)
}
Functionally identical. Visually identical. One ships HTML, one ships HTML plus a runtime. For pages people read once and leave, that runtime is dead weight.
Astro 5 features that made it possible
Three things in Astro 5 turned this from a fun experiment into a real migration.
Server islands changed the contact form story. In Astro 4 I would have shipped the form as a client island, hydrated React for it, and lived with the ~30 KB cost. With server islands, the form renders on the server on every visit, while the rest of the page stays static. The marker is a single attribute:
Loading form...
The shell ships as static HTML. The form fetches from the edge after first paint. Zero React on the client. The fallback handles the brief gap. For three forms across three sites, that saved me from shipping any client framework at all.
The content layer was the other big one. The blog had 47 MDX files and a custom loader that walked the filesystem, parsed frontmatter, generated typed exports, and broke any time I touched it. The new content layer replaces all of that with a config:
// astro.config.mjs
import { defineConfig } from 'astro/config'
import mdx from '@astrojs/mdx'
import sitemap from '@astrojs/sitemap'
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap()],
experimental: { contentIntellisense: true },
})
// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'
const posts = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
schema: z.object({
title: z.string(),
pubDate: z.date(),
tags: z.array(z.string()),
}),
})
export const collections = { posts }
Schema validation, typed queries, automatic incremental updates. The blog build dropped from 1m 47s to 41s, partly because of this. My custom loader went in the bin.
Sessions are the third one. The portfolio uses a tiny "saved projects" feature where someone can star case studies for later. In Next.js I had a cookie helper, a route handler, and a small client component. In Astro 5 sessions are first-class. A few lines in the config, a server function, done. No third-party session library, no extra route, no client framework.
Where Next.js still wins
I am not putting a dashboard on Astro. I am not putting a real app on Astro. Honesty matters here.
If the product surface has authenticated state, optimistic updates, real-time data, or anything resembling an SPA, Next.js is the better tool. RSC streaming is genuinely good when the page is doing actual work. Suspense boundaries, partial prerender, parallel routes, intercepting routes, all of it pays off when you have an interface that updates constantly. Astro can host an island with React or Solid or Svelte inside it, but at that point you are using Astro as a shell around a thing it was not built for.
Edge middleware is the other place Next.js still wins for me. Geo routing, A/B tests, cookie-based redirects at the edge in single digit milliseconds. Astro has middleware too, but the Vercel-tuned version of Next middleware is hard to beat for the marketing sites that genuinely need it. None of mine did, which is part of why the migration worked, but if yours does, factor it in.
The other point worth naming: ecosystem gravity. There are 10 Next.js tutorials for every Astro one. Most starter templates assume Next. If you are hiring or onboarding, that matters. I am a solo studio, so it does not matter to me. Your team might feel it.
Bottom Line
Pick by use case, not loyalty. If the site is mostly content and the JavaScript you ship is mostly dead weight, Astro 5 is the right call now. The bundle savings, the build speed, and the dev server cold start are all real, and server islands plus the content layer close the last gaps that used to send people back to Next.
If the site is an actual app with auth, dashboards, real-time data, or heavy client interaction, stay on Next.js. That is where it belongs and that is where it shines. Forcing Astro to be an SPA framework defeats the point.
For my next marketing site, Astro 5 is the default. For my next product, Next.js stays. I should have made the split sooner. If you have a static site bleeding 180 KB of React for no reason, block off a weekend, branch the repo, and run the same numbers I did. The decision will make itself.
Top comments (0)