Migrating Next.js to Astro: What Changes and Why

Moving a content site from Next.js to Astro on Cloudflare: why static HTML with islands is lighter, how components and data fetching translate, and where a homelab API fits.

Diagram of a Next.js site being replaced by a static Astro site served from a global edge network

Many personal sites and portfolios built with Next.js are, in practice, static content: text, images, a list of projects. Next.js brings server-side rendering, API routes and a client-side React runtime that hydrates the page in the browser. For a content site, much of that work buys nothing.

Astro takes the opposite default: pages are rendered to static HTML at build time and ship no JavaScript unless you opt in, component by component (“islands”). For content-heavy sites that usually means less JavaScript to download and execute, which is what Core Web Vitals such as Total Blocking Time and Largest Contentful Paint measure. How big the difference is depends on your site, so measure your own before and after with Lighthouse or PageSpeed Insights.

Hosting a static build is cheap too: Cloudflare Pages, for example, serves static sites on its free plan.

Why the difference exists

A Next.js page is server-rendered and then hydrated: the browser downloads the React runtime and the page’s components and runs them to make the HTML interactive. An Astro page without client directives is just HTML and CSS: there’s nothing to hydrate, so the main thread stays free. Interactive parts are added only where you mark them, and only their JavaScript ships.

The Migration: It’s a Mindset Shift

You don’t just convert Next.js files to Astro files. You have to unlearn the “everything is a component” reflex.

In Next.js, your page is a tree of React components. In Astro, your page is an .astro file that looks like HTML with a code fence at the top. This is where you fetch data at build time. It’s a return to simplicity.

Here’s the core change:

  • getServerSideProps / RSC fetch: Gone. You now use await fetch() at the top of your .astro file. This code runs once, during the build, and the result is baked into the static HTML.
  • React Components: You can keep them. Astro has official integrations for React, Svelte, Vue, and more. The difference is they are not interactive by default.

A typical project card component in Next.js might look like this:

// src/components/ProjectCard.tsx (Next.js)
interface Props {
  title: string;
  description: string;
  repoUrl: string;
}

export const ProjectCard = ({ title, description, repoUrl }: Props) => {
  return (
    <div className="card">
      <h3>{title}</h3>
      <p>{description}</p>
      <a href={repoUrl}>View Code</a>
    </div>
  );
};

In Astro, you put this component in src/components/ and use it almost identically inside an Astro page, but you control its JavaScript footprint.

---
// src/pages/projects.astro
import { ProjectCard } from '../components/ProjectCard.tsx';
const response = await fetch('https://my-homelab-api/projects');
const projects = await response.json();
---
<html lang="en">
  <head>
    <title>My Projects</title>
  </head>
  <body>
    <h1>Projects</h1>
    <div class="project-grid">
      {projects.map(project => (
        <ProjectCard
          title={project.name}
          description={project.description}
          repoUrl={project.repo_url}
          client:visible
        />
      ))}
    </div>
  </body>
</html>

The client:visible directive is the key. It tells Astro: “This is a React component. Don’t ship its JavaScript to the browser until the component itself scrolls into the viewport.” If your component is purely presentational (no state, no effects), you can omit the client:* directive entirely. Astro will render it to HTML and ship zero JavaScript for it. That’s where most of the savings come from.

Connecting to Your Homelab API

Many sites have a dynamic data source, even a portfolio: a headless CMS like Strapi, or a small API running in a container on a Proxmox server. This is where the homelab angle gets interesting. Your site is static, but your data is dynamic at build time.

During development (npm run dev), Astro can fetch from http://localhost:1337. But during a build on Cloudflare, it needs a public URL. You can expose your homelab API via a Cloudflare Tunnel or a reverse proxy.

Here’s a dead-simple docker-compose.yml for a backend that serves project data. This could be a stand-in for your own API.

# docker-compose.yml
services:
  portfolio-api:
    image: node:20-alpine
    container_name: portfolio-api
    restart: unless-stopped
    ports:
      - "1337:1337"
    volumes:
      - ./api:/usr/src/app
    working_dir: /usr/src/app
    command: sh -c "npm install && npm start"
    environment:
      - NODE_ENV=production

networks:
  default:
    name: homelab_net

Your build process simply needs an environment variable, API_BASE_URL.

# In your .env file
API_BASE_URL="http://192.168.1.50:1337/api"

# In your CI/CD pipeline (e.g., Cloudflare Pages settings)
# Add an environment variable with the same name and public URL
API_BASE_URL="https://my-public-api.domain.com/api"

Then your Astro page fetches from it:

---
const API_URL = `${import.meta.env.API_BASE_URL}/projects`;
const response = await fetch(API_URL);
const projects = await response.json();
---
<!-- Rest of the page -->

This is the perfect hybrid model. You get the stability and low maintenance of a self-hosted API with the speed and zero cost of a static global deployment.

The Deployment Payoff

Deploying to Cloudflare Pages is almost insultingly simple.

  1. Connect your GitHub repository.
  2. Select “Astro” as the framework preset.
  3. Set the build command to npm run build and the output directory to /dist.
  4. Add your API_BASE_URL environment variable in the dashboard.

That’s it. Every git push triggers a new build and deployment. The site is served from Cloudflare’s global edge network, so it’s fast everywhere.

Is It Worth It?

For a content site, usually yes: less JavaScript, simpler code, cheap static hosting and a clear split between static content and the few interactive islands. Measure before and after on your own pages to see the real gain.

Stay on Next.js if the site is really an application: lots of client-side state, authenticated dashboards, server actions everywhere. Astro can do on-demand rendering too, but its strength is content.

What’s Next

Frequently Asked Questions

Can I still use my React components after migrating to Astro?
Yes. Astro's island architecture is designed for this. You can drop in React, Svelte, or Vue components and hydrate them only when needed using `client:*` directives. Your component code barely changes.
My Next.js site has a contact form. How do I handle that in a static Astro site?
You can't run a backend endpoint on a static host. The best options are using a serverless function (Cloudflare Pages Functions are perfect for this) or pointing the form to a small, self-hosted API endpoint in your homelab.
Is Astro 5 a huge change from older versions? Does this guide still apply?
The approach in this guide (static pages, islands for interactivity, build-time data fetching) is Astro's core model and applies to Astro 5 and later versions. Check the upgrade guide of the version you use for API details such as content collections.

Get notified when new articles and designs land:

No spam. Unsubscribe any time.

Sergej Voronko
Sergej Voronko
SAP Basis · Senior Operations Manager · Linux infrastructure engineer
About the author →

[discussion]

Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.