Migrating from Next.js Pages Router to Astro
Many content sites built on Next.js between 2019 and 2022 use the Pages Router in its most static form: every page is generated with getStaticProps and getStaticPaths, content comes from Markdown or a headless CMS at build time, and there is little client-side state. Those sites ship React, the Next.js runtime and a JSON payload per page to every reader, and hydrate the whole page even when nothing on it is interactive. Moving them to Astro keeps the React components but renders them to HTML only, hydrating the few that need it.
This guide migrates a 380-page Next.js 13 marketing site and blog (Pages Router, static export, MDX blog, a headless CMS for landing pages, next/image) to Astro 4. It maps routes and data fetching, keeps React components, replaces Next.js-specific APIs, preserves URLs, and measures the result. It is part of Migrating Between Static Site Generators; the decision itself is discussed in Next.js Static Export vs Astro for Marketing Sites.
Prerequisites
- An inventory of the Next.js site: pages, data sources,
next.config.jsrewrites and redirects, components that usenext/router,next/link,next/imageornext/head, and the full URL list. - An Astro 4.16+ project with
@astrojs/reactand@astrojs/mdx. - A redirect mechanism on the target host and the ability to run both sites side by side for comparison.
Step 1: Map Routes One to One
Astro's file-based routing is close enough to the Pages Router that most files move directly:
| Next.js Pages Router | Astro |
|---|---|
pages/index.tsx | src/pages/index.astro |
pages/about.tsx | src/pages/about.astro |
pages/blog/[slug].tsx + getStaticPaths | src/pages/blog/[slug].astro + getStaticPaths |
pages/[...slug].tsx (CMS catch-all) | src/pages/[...slug].astro |
pages/_app.tsx | a shared layout component |
pages/_document.tsx | the layout's <html> and <head> |
pages/404.tsx | src/pages/404.astro |
pages/api/* | Astro endpoints (static) or an edge function |
The site had two API routes — a newsletter signup and a contact form — which became Cloudflare Workers, as in Newsletter Signup with an Edge Function.
Step 2: Move Data Fetching Into the Page
getStaticProps split data fetching from rendering; in Astro, the component script runs at build time and fetches directly:
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', (p) => !p.data.draft);
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
const { Content, headings } = await post.render();
---
<BlogLayout title={post.data.title} headings={headings}><Content /></BlogLayout>
The blog's hand-written loader — fs.readdirSync, gray-matter, next-mdx-remote serialization — was replaced by a content collection with a Zod schema, which also caught 11 posts with invalid dates that the old loader had silently accepted. CMS landing pages kept their fetch logic, moved from getStaticProps into the page script, with the API token read from import.meta.env.
Step 3: Keep React Components, Hydrate Only Where Needed
The site had 64 React components. After @astrojs/react was added, 58 rendered unchanged as static HTML inside Astro layouts. The rest needed attention:
- Components using
next/link. Replaced with plain<a>elements; Astro has no client router by default. Prefetching came back through Astro'sprefetchoption, as in Prefetching Links in Astro. - Components using
next/router. Two components read the current path for active navigation styling; they now receiveAstro.url.pathnameas a prop. next/head. Replaced by props on the layout, which renders<title>, meta and canonical tags.next/image. Replaced by Astro's<Image />and<Picture />fromastro:assets— see Step 4.
Only four components were interactive: a pricing calculator, a mobile menu, a newsletter form and a testimonial carousel. They received client:visible or client:idle directives. Everything else, including the header, footer and every blog component, now ships no JavaScript.
Step 4: Images
next/image in static export required either unoptimized: true or a custom loader, and this site had used Cloudinary through a loader. Astro's astro:assets optimizes local images at build time into AVIF and WebP with srcset. The team moved the 900 images that lived in the repository to src/assets/ and processed them at build time, and kept Cloudinary only for CMS-hosted images through Astro's remote image support with image.domains configured. Hero images got loading="eager" and fetchpriority="high"; details are in Optimizing Images in Next.js Static Export for the before state and Image Optimization Pipelines in Astro for the after.
Step 5: Preserve URLs and Redirects
The Next.js site used trailingSlash: true; Astro was configured the same way with trailingSlash: 'always'. The three rewrites in next.config.js became real routes, and the 60 redirects moved to the host's redirect file, generated from a single data file as in Keeping Redirects Working After an SSG Migration. A crawl of both sites compared URL lists, titles, canonicals and status codes; the parity approach is in Auditing Content Parity After a Migration. Two blog posts turned out to have been reachable at two URLs in Next.js because of a catch-all route; the migration kept one and redirected the other.
Step 6: Analytics, Scripts and Head Tags
_app.tsx often accumulates scripts — analytics, tag managers, chat widgets — loaded with next/script strategies. Each needs an explicit home in Astro. On this site, the analytics script moved to the layout with defer; Google Tag Manager moved into a Partytown worker; the chat widget moved behind a facade that loads on click. Page-view tracking in single-page apps usually hooks router events; with normal page loads in Astro, the default page-view tracking of most analytics tools just works, and the custom routeChangeComplete handler was deleted.
Running Both Sites During the Move
The migration took five weeks, during which marketing kept publishing to the CMS and the blog. Two practices kept the old and new sites in step. Both builds were triggered by the same CMS webhook, so every published change appeared on the Next.js production site and on the Astro preview at the same time, and the parity comparison could run against identical content. And the blog's MDX files were never edited during the migration branch's lifetime without the same edit landing on main; a pre-merge check compared the content/blog directories of both branches and failed on any difference, which caught two posts that would otherwise have been lost at cut-over.
Cut-over itself was a host alias change at 10:00 on a Tuesday, chosen because traffic was high enough to see problems quickly in real-user monitoring and the whole team was around. The Next.js deployment stayed live on a separate hostname for four weeks as a rollback target; it was never needed.
Measured Impact
Field data from the site's RUM, four weeks before and after, and build logs:
| Measure | Next.js 13 Pages Router | Astro 4.16 |
|---|---|---|
| JavaScript on a landing page (brotli) | 162 KB | 14 KB |
| JavaScript on a blog post (brotli) | 139 KB | 0 KB |
| LCP p75, mobile | 2.9 s | 1.7 s |
| INP p75 | 240 ms | 70 ms |
| CLS p75 | 0.09 | 0.02 |
| Build time | 3 min 55 s | 1 min 40 s |
| Lines of data-loading code | 610 | 140 |
Pitfalls & Rollback
- Assuming client-side navigation. Components that kept state across route changes lose it; check carousels, forms and menus.
- Hidden
next/*imports in shared components. Search for everynext/import before starting. getStaticPropsrevalidation. ISR has no static equivalent; rebuild on CMS changes with a build hook, as in Netlify Build Hooks for Content Updates.- Analytics page views. Remove single-page-app router hooks, or page views are counted twice.
- Rollback: keep the Next.js build deployable on a separate subdomain for the first weeks; switching the production domain back is a DNS or host alias change.
Conclusion
A content-first Next.js Pages Router site maps onto Astro almost file for file. Data fetching moves into page scripts, React components mostly render unchanged as static HTML, and only genuinely interactive components keep their JavaScript. On this site that cut JavaScript by more than 90%, brought LCP and INP well inside their thresholds, and more than halved the build.
FAQ
When does moving from Next.js to Astro make sense?
When the site is mostly content, such as a marketing site, blog or documentation, and uses Next.js largely for static generation. Astro renders the same pages as static HTML with no framework runtime by default, which reduces JavaScript and usually improves LCP and INP. Apps with heavy client-side state benefit less.
Can I reuse my React components?
Yes. Astro's React integration renders React components on the server as static HTML, and hydrates them in the browser only where you add a client directive. Most presentational components can be reused unchanged; components relying on Next.js APIs such as next/router or next/image need small changes.
What replaces getStaticProps and getStaticPaths?
Astro pages fetch data directly in the component script at build time, and dynamic routes export getStaticPaths, which works much like Next.js's version. Content collections replace most hand-written loaders for Markdown and MDX.
Do URLs change?
Not necessarily. Astro's file-based routing mirrors the Pages Router closely, and trailingSlash can match the old configuration. Differences usually come from rewrites in next.config.js and from i18n routing, which need explicit handling.
Related
- Parent: Migrating Between Static Site Generators — general migration practice.
- Migrating from Gatsby to Astro — a similar React-to-Astro move.
- Next.js App Router Static Export Limitations — the alternative of upgrading within Next.js.
- Astro Islands vs Full Hydration Performance — why the JavaScript drop matters.
- Using MDX in Next.js Static Export — the MDX setup being replaced.