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/ RSCfetch: Gone. You now useawait fetch()at the top of your.astrofile. 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.
- Connect your GitHub repository.
- Select “Astro” as the framework preset.
- Set the build command to
npm run buildand the output directory to/dist. - Add your
API_BASE_URLenvironment 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.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.