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:
- Set up an Astro project alongside the existing Next.js app
- Pick one representative route (ideally the one causing the most pain)
- Port it to Astro, including metadata, data fetching, and any interactive islands
- Validate: correct metadata, caching behavior, visual parity
- Repeat for more routes
- Cut over via subdomain or proxy once enough routes are proven
Feature mapping
Pages and routing
| Next.js | Astro |
|---|---|
app/page.tsx | src/pages/index.astro |
app/blog/[slug]/page.tsx | src/pages/blog/[slug].astro |
app/[...path]/page.tsx | src/pages/[...path].astro |
layout.tsx (nested) | Layout components (manual wrapping) |
loading.tsx | Not applicable (no React tree to suspend) |
error.tsx | Try/catch in frontmatter |
not-found.tsx | src/pages/404.astro |
Data fetching
| Next.js | Astro |
|---|---|
fetch() in server component | fetch() in frontmatter (top-level await) |
generateStaticParams() | getStaticPaths() |
| Dynamic server component | export const prerender = false |
"use server" server actions | Astro 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.js | Astro |
|---|---|
export const revalidate = 60 | Cache-Control headers on on-demand routes |
revalidateTag() | Experimental route caching (Astro 6) or CDN purge API |
revalidatePath() | CDN purge or rebuild |
unstable_noStore | On-demand route (no caching by default) |
| ISR | CDN 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.js | Astro replacement |
|---|---|
next/font/google | Astro 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/cache | HTTP headers + CDN |
React components
Most React components port directly:
- Static display components - Convert to
.astrocomponents (remove hooks, use frontmatter for data) - Client interactive components - Keep as
.jsx/.tsx, addclient:*directive - 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