← Blogs

Why I Moved from Next.js to Astro

astronext.jsperformancecloudflare

For months, my portfolio sat at 95 on mobile PageSpeed. I’d tweak one thing, gain a point, lose it somewhere else. The ceiling was real and I couldn’t break through.

Then I migrated to Astro. First run: four 100s.

The Problem with Next.js

My site isn’t complex. A hero, about section, project grid, experience timeline, and a footer. One interactive component (the project grid modal). Everything else is static content.

Next.js shipped ~450KB of JavaScript for this:

  • 214KB Next.js runtime (App Router, hydration, client router)[1]
  • 123KB motion/react (used by one component)
  • 113KB browser polyfills (Turbopack shared chunks)
  • 84KB Radix UI (dropdown menu for a theme switcher)[2]

I rewrote the theme switcher to remove Radix. I swapped barrel imports for direct imports to help tree-shaking. I wrapped the footer in dynamic(() => import(...), { ssr: false }). Nothing moved the needle past 95 on mobile.

The real problem: Next.js hydrates the entire page.[3] Every component ships JavaScript regardless of whether it needs interactivity. For a content site, that’s overhead you can’t optimize out of existence.

What Astro Changes

Astro defaults to zero JavaScript.[4] Components render to HTML at build time. You opt into interactivity only where you need it, per component.

My entire site now has one hydrated island:

<ProjectGrid client:visible />

That’s it. Nothing else ships JavaScript. The hero, about section, timeline, footer — all static HTML. The project grid only hydrates when it scrolls into view.

The Migration

One commit: 9,300 insertions, 10,861 deletions across 98 files. Looks dramatic, but most of it was just moving files and converting React to .astro.

What stayed the same

  • React components — ProjectGrid, HeroSection, AboutSection kept their .tsx files. Astro renders them server-side unless you add a client directive.
  • Tailwind CSS — moved from PostCSS to the Vite plugin (@tailwindcss/vite). Same utility classes, faster builds.
  • Motion — the project grid modal animation is identical. Same layoutId, same springs, same AnimatePresence.
  • Design system — every token, spacing constant, and animation config carried over unchanged.

What changed

  • Routing — from Next.js App Router to Astro file-based routing. Simpler, no nested layouts to think about.
  • Theme switching — from a React context provider to an inline <script> in the <head> that reads localStorage before paint. No flash, no hydration.
  • CSS delivery — build.inlineStylesheets: "always" inlines everything into the HTML.[5] One fewer network request, immediate paint.
  • Footer — was a React component loaded with dynamic(). Now a .astro file, zero JavaScript overhead.
  • Theme dropdown — was a React component using createPortal. Now a server-rendered button with an inline script. No Radix, no motion, no portal.

The theme script

This is ~300 bytes and runs before paint (blocking, deliberately):

<script is:inline>
  (() => {
    const saved = localStorage.getItem("theme");
    const theme = ["dark", "light", "nightingale"].includes(saved) ? saved : "dark";
    if (theme === "light") document.documentElement.removeAttribute("data-theme");
    else document.documentElement.setAttribute("data-theme", theme);
  })();
</script>

No flash. No hydration mismatch. No useEffect dance.

Deploying to Cloudflare

I moved from Vercel to Cloudflare Workers with static assets.[6] Minimal config:

{
  "assets": { "directory": "./dist" },
  "routes": [{ "pattern": "xeind.net", "custom_domain": true }],
}

Astro builds to ./dist, Wrangler deploys it. Static files served from Cloudflare’s edge globally.

Cache headers

Astro fingerprints CSS and JS with content hashes under /_astro/. These get permanent caching:

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

/fonts/*
  Cache-Control: public, max-age=31536000, immutable

HTML pages get no-cache so deployments take effect immediately.

The Numbers

MetricNext.js (Vercel)Astro (Cloudflare)
Performance95-97100
Accessibility100100
Best Practices100100
SEO100100
JS shipped~450KB~125KB
Hydrated componentsAll1

The remaining ~125KB is motion/react for the project grid modal. Everything else is HTML and inlined CSS.

What I’d Do Differently

I shouldn’t have started with Next.js for a portfolio. The App Router is excellent for apps with dynamic data, authentication, and server actions. But a portfolio? It’s static content with maybe one interactive widget.

If you’re building content-forward site, start with Astro. Add React islands where you need them. Going the other way (trying to make React ship less JavaScript) is swimming upstream.

The source for this site is on GitHub.