We use analytics to understand how our website is used. No personal data is collected.

July 20, 2026 · Piyush Ranjan Mishra

From Next.js to Astro: A Real Migration Case Study

AstroNext.jsMigrationPerformance
From Next.js to Astro: A Real Migration Case Study

Most “Next.js to Astro” posts are written before anyone has actually shipped the migration. This one isn’t. I recently moved this site — a marketing site with a multi-step questionnaire, a dark-themed portfolio page with an AI chat widget, shadcn/Radix components, and a Next.js API route calling three different LLM providers — from Next.js 14 to Astro. Here’s what that actually involved.

The starting point

The app wasn’t a simple blog. It had:

  • A homepage built from ~10 composed React components (Header, Hero, Questionnaire, CaseStudy, and so on), each with its own CSS module
  • A /portfolio page with heavy framer-motion animation, useState-driven chat UI, and a scroll-spy nav
  • An /api/chat route that called OpenAI, fell back to Gemini, then fell back to Claude
  • next.config.js set to output: "export" — so it was already a static export, which turned out to matter a lot

That last point is the one people miss. If you’re running next export today, you’re already halfway to a static-site architecture. The migration isn’t “server-rendered to static” — it’s “one static-site framework to another.”

Decision 1: keep React, or rewrite in native Astro?

Astro doesn’t require you to drop React. @astrojs/react lets you keep every .tsx component exactly as it is and mount it as an island — a chunk of interactive React that hydrates independently of the rest of the page. For a component library with 50+ shadcn/Radix components already built and tested, rewriting them in vanilla Astro would have been weeks of work for zero user-facing benefit. I kept every component and made a per-component call on hydration strategy instead:

<Header {...data.headerData} client:load />
<Hero {...data.heroData} client:visible />
<CaseStudy {...data.caseStudyData} />
<Questionnaire {...data.questionnaireData} client:visible />

Header gets client:load because the mobile menu needs to work the instant the page paints. Hero and Questionnaire get client:visible because they’re scroll-triggered or below the fold anyway. CaseStudy gets no directive at all — it turned out to have zero useState/useEffect calls, so it doesn’t need to hydrate as React at all. It ships as static HTML with zero JS. That’s not something Next.js’s App Router gives you for free at the component level without manually wiring up Server Components — Astro’s island model makes it the default question to ask for every component.

Decision 2: what happens to the API route?

output: "export" in Next.js already meant /api/chat wasn’t actually running on the same server as the static export in production — it needed a separate backend. Astro’s static build has the same constraint: no server-side code ships with a static dist/. So the chat endpoint moved to a Firebase Cloud Function, and I used a Firebase Hosting rewrite to keep the frontend’s fetch("/api/chat") call working unchanged:

{
  "hosting": {
    "rewrites": [
      {
        "source": "/api/chat",
        "function": { "functionId": "chat", "region": "us-central1" }
      }
    ]
  }
}

Same-origin, zero frontend changes, and now the AI failover logic (OpenAI → Gemini → Claude) lives somewhere it can actually scale independently of the static site.

What actually broke

Two things, both instructive:

1. Type-only imports. Several components did import { FooterProps } from "@/types" instead of import type { FooterProps } from "@/types". Next’s webpack pipeline silently stripped these at compile time. Astro’s rolldown-based bundler treats a value-position import of a type as a genuine missing export and fails the build. The fix is mechanical — import type everywhere a type is being imported — but it’s a real difference in strictness worth knowing about before you hit it on a 50-component codebase.

2. Trailing slashes weren’t cosmetic. One page had <img src="1.png"> — a relative path that only resolved correctly because Next’s trailingSlash: true made /portfolio/ the canonical URL, so the browser resolved 1.png against that directory. Astro’s dev server, with trailingSlash: "ignore", will happily serve /portfolio without a trailing slash, which silently breaks that relative resolution. The real fix isn’t a config flag — it’s not depending on trailing-slash behavior for asset paths. I made every image reference absolute (/portfolio/1.png) instead.

The result

Same design, same interactions, same AI chat — but the parts of the page with no interactivity now ship no JavaScript, and the build step is meaningfully faster. If you’re already running a static export from Next.js, the migration is more mechanical than architectural. If you’re not — if you’re relying on Server Components, middleware, or ISR — that’s a different, harder conversation, and one worth having before you start moving files.