Next.js Migration

A practical guide for migrating a Next.js App Router site to Astro. Grounded in real patterns from content-heavy sites with API backends, React components, metadata, and caching.

When migration makes sense

Astro is a better fit than Next.js when:

  • The site is primarily content (news, blog, docs, marketing)
  • Most pages are read-only, fetching from an API or CMS
  • Interactivity is limited to isolated widgets, not whole-page React trees
  • Metadata correctness and SEO are critical
  • You want simpler mental models for caching and rendering

Astro is a worse fit when:

  • The site is a full web application (dashboard, SPA, collaborative tool)
  • Most pages require heavy client-side state
  • You need React Server Components’ streaming model

Migration strategy

Do not rewrite everything at once. Migrate route-by-route:

  1. Set up an Astro project alongside the existing Next.js app
  2. Pick one representative route (ideally the one causing the most pain)
  3. Port it to Astro, including metadata, data fetching, and any interactive islands
  4. Validate: correct metadata, caching behavior, visual parity
  5. Repeat for more routes
  6. Cut over via subdomain or proxy once enough routes are proven

Feature mapping

Pages and routing

Next.jsAstro
app/page.tsxsrc/pages/index.astro
app/blog/[slug]/page.tsxsrc/pages/blog/[slug].astro
app/[...path]/page.tsxsrc/pages/[...path].astro
layout.tsx (nested)Layout components (manual wrapping)
loading.tsxNot applicable (no React tree to suspend)
error.tsxTry/catch in frontmatter
not-found.tsxsrc/pages/404.astro

Data fetching

Next.jsAstro
fetch() in server componentfetch() in frontmatter (top-level await)
generateStaticParams()getStaticPaths()
Dynamic server componentexport const prerender = false
"use server" server actionsAstro endpoints or form actions

Metadata

Next.js uses generateMetadata() or static metadata exports. Astro uses standard HTML:

---
// Astro equivalent of generateMetadata()
const title = "My Page";
const description = "Page description";
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
---
<html>
  <head>
    <title>{title}</title>
    <meta name="description" content={description} />
    <link rel="canonical" href={canonicalUrl} />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
  </head>
  <body>
    <slot />
  </body>
</html>

This is more verbose but completely transparent. No framework magic, no surprising behavior. You control exactly what ends up in <head>.

Caching and revalidation

Next.jsAstro
export const revalidate = 60Cache-Control headers on on-demand routes
revalidateTag()Experimental route caching (Astro 6) or CDN purge API
revalidatePath()CDN purge or rebuild
unstable_noStoreOn-demand route (no caching by default)
ISRCDN s-maxage + stale-while-revalidate headers

The simplest approach for a first migration: use explicit Cache-Control headers and let your CDN handle caching. Do not depend on Astro’s experimental route caching for production yet.

Redirects and middleware

// Next.js middleware.ts
export function middleware(request) {
  if (request.nextUrl.pathname === '/daily/today') {
    return NextResponse.redirect(new URL('/daily/2026-04-18', request.url));
  }
}
// Astro src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware((context, next) => {
  if (context.url.pathname === '/daily/today') {
    const today = new Date().toISOString().split('T')[0];
    return context.redirect(`/daily/${today}`);
  }
  return next();
});

Route handlers / API endpoints

// Next.js app/api/feed/route.ts
export async function GET() {
  const data = await fetchFeed();
  return new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' },
  });
}
// Astro src/pages/api/feed.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async () => {
  const data = await fetchFeed();
  return new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' },
  });
};

Nearly identical. The main difference is file location and the APIRoute type.

Framework-specific replacements

Next.jsAstro replacement
next/font/googleAstro Fonts API (astro:fonts)
next/script<script> tags (Vite-bundled) or inline <script is:inline>
next/image<Image /> from astro:assets
next/link<a href="..."> (standard HTML, or view transitions for SPA-like nav)
next/navigation (useRouter, usePathname)Astro.url in server code, or client-side window.location
next/og (OG image generation)Use @vercel/og directly, Satori, or a custom endpoint
next/cacheHTTP headers + CDN

React components

Most React components port directly:

  1. Static display components - Convert to .astro components (remove hooks, use frontmatter for data)
  2. Client interactive components - Keep as .jsx/.tsx, add client:* directive
  3. Components using Next.js APIs - Rewrite the Next-specific parts, keep the UI

The migration cost is not “rewrite all React.” It is “replace the framework contract around the React.”

Practical example: news story page

Next.js version:

// app/daily/[date]/[story]/page.tsx
export async function generateMetadata({ params }) {
  const story = await fetchStory(params.date, params.story);
  return { title: story.title, description: story.summary };
}

export default async function StoryPage({ params }) {
  const story = await fetchStory(params.date, params.story);
  if (!story) notFound();
  return <StoryLayout story={story} />;
}

Astro version:

---
// src/pages/daily/[date]/[story].astro
export const prerender = false;

import Base from '../../../layouts/Base.astro';
import StoryContent from '../../../components/StoryContent.astro';

const { date, story: storySlug } = Astro.params;
const storyData = await fetch(`${API_URL}/daily/${date}/${storySlug}`).then(r => r.json());

if (!storyData) {
  return Astro.redirect('/404');
}

Astro.response.headers.set('Cache-Control', 'public, max-age=120, s-maxage=300');
---
<Base title={storyData.title} description={storyData.summary}>
  <StoryContent story={storyData} />
</Base>

The Astro version is more explicit: metadata is in the HTML template, caching is an HTTP header, not-found is a redirect. No hidden framework behavior.

Migration checklist

  • Set up Astro project with React integration
  • Create base layout replacing the Next.js root layout
  • Port metadata pattern (title, description, OG tags, structured data)
  • Port first route with data fetching
  • Port redirect/not-found handling
  • Port RSS, sitemap, and text endpoints
  • Convert authored content to content collections
  • Set up fonts (Astro Fonts API)
  • Wire up ads/analytics scripts
  • Validate metadata with social preview tools
  • Set up CDN caching headers
  • Deploy and test alongside existing site