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.js rewrites and redirects, components that use next/router, next/link, next/image or next/head, and the full URL list.
  • An Astro 4.16+ project with @astrojs/react and @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 RouterAstro
pages/index.tsxsrc/pages/index.astro
pages/about.tsxsrc/pages/about.astro
pages/blog/[slug].tsx + getStaticPathssrc/pages/blog/[slug].astro + getStaticPaths
pages/[...slug].tsx (CMS catch-all)src/pages/[...slug].astro
pages/_app.tsxa shared layout component
pages/_document.tsxthe layout's <html> and <head>
pages/404.tsxsrc/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.

Data flow for a page in Next.js Pages Router and in Astro In Next.js, getStaticProps fetches from the CMS at build time and returns props, the page component renders, and the same props are serialized to a JSON payload that the browser downloads and uses to hydrate the whole page. In Astro, the page script fetches from the CMS at build time and renders HTML; only islands with a client directive receive props in the browser. Where data is fetched, before and after CMS API build time Next.js page getStaticProps Astro page component script Browser reader fetch landing page data HTML + JSON payload + React + runtime hydrate the whole page fetch landing page data HTML, plus JS only for islands
The JSON payload and full-page hydration were a quarter of the bytes on a typical landing page.

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's prefetch option, as in Prefetching Links in Astro.
  • Components using next/router. Two components read the current path for active navigation styling; they now receive Astro.url.pathname as 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 /> from astro: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.

React components by migration outcome Horizontal bars. 54 components rendered unchanged as static HTML. 4 needed small changes to replace next/link, next/router or next/head and then rendered statically. 4 interactive components kept React with a client directive. 2 were replaced entirely by Astro image components. What happened to 64 React components Unchanged, static HTML 54 Small changes, static HTML 4 Interactive island 4 Replaced by astro:assets 2
Only 4 of 64 components ship JavaScript after the migration.

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:

MeasureNext.js 13 Pages RouterAstro 4.16
JavaScript on a landing page (brotli)162 KB14 KB
JavaScript on a blog post (brotli)139 KB0 KB
LCP p75, mobile2.9 s1.7 s
INP p75240 ms70 ms
CLS p750.090.02
Build time3 min 55 s1 min 40 s
Lines of data-loading code610140
Field LCP and INP before and after moving to Astro Paired bars of field p75 values. LCP on mobile went from 2.9 seconds on Next.js to 1.7 seconds on Astro. INP went from 240 milliseconds to 70 milliseconds, drawn on the same seconds scale. Core Web Vitals before and after Next.js Pages Router Astro LCP p75 mobile 2.9 s 1.7 s INP p75 240 ms 70 ms
Four weeks of RUM data per period; both periods had similar traffic mix.

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 every next/ import before starting.
  • getStaticProps revalidation. 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.

Filed underHow-tosAstroNext.js