My Next.js portfolio was a solution in search of a problem. It had server-side rendering, API routes, and a complex build process because, well, that’s the Next.js way. It was deployed on Vercel, and while the free tier is generous, the entire setup felt like overkill. I was shipping a multi-megabyte React runtime to display what was essentially static text and images. The complexity was a hidden cost. The real cost was slow builds and mediocre mobile performance scores.
I ripped it all out and moved to Astro 5.
The goal wasn’t just to try a new framework. It was to build a site that was brutally fast, cost nothing to host, and required minimal mental overhead. Astro’s philosophy is simple: ship zero JavaScript by default. Your site is static HTML and CSS, and you can opt-in to interactivity with “islands” of your preferred framework (React, in my case). For a portfolio, blog, or documentation site, this model just makes more sense.
The Hard Numbers: Before and After
Talk is cheap. Let’s look at the metrics. The “Before” is my Next.js 14 App Router site. The “After” is the same content migrated to Astro 5. Both are deployed on Cloudflare Pages’ free tier for an apples-to-apples comparison of the final asset delivery.
| Metric | Next.js 14 (App Router) | Astro 5 (Static) | Result |
|---|---|---|---|
| PageSpeed Insights (Mobile) | 78 | 99 | +27% |
| Largest Contentful Paint (LCP) | 2.1s | 0.8s | 62% Faster |
| Total Blocking Time (TBT) | 180ms | 0ms | 100% Reduction |
| First Contentful Paint (FCP) | 1.3s | 0.6s | 54% Faster |
| Total JS Shipped (Homepage) | ~115 KB | ~4 KB | 96% Smaller |
| Build Time (CI/CD) | 2m 15s | 35s | 74% Faster |
| Monthly Hosting Cost | $0 (but with limits) | $0 (with higher limits) | Effectively Zero |
The results aren’t surprising if you understand the underlying tech. The Next.js site required hydration, where the server-rendered HTML is made interactive on the client by executing JavaScript. Astro spits out pure, static HTML. There’s no hydration to perform and no JavaScript to parse unless I explicitly add an interactive component. The Total Blocking Time dropping to zero tells the whole story: the main thread is free.
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. This is the source of the massive performance gains.
Connecting to Your Homelab API
Most of us have a dynamic data source, even for a portfolio. Maybe it’s a headless CMS like Strapi or a custom API running in a container on our 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
version: '3.8'
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, which now takes seconds instead of minutes. The site is served from Cloudflare’s global edge network, so it’s fast everywhere.
Was It Worth It?
Unequivocally, yes. The performance benefits are real and measurable. My Lighthouse scores are consistently 99-100. The hosting cost is zero and the build limits are so high I’ll never hit them.
The biggest win, however, is simplicity. The Astro project is easier to reason about. There’s less boilerplate and a clear separation between static content and interactive islands. I’m no longer fighting a complex framework designed for web apps just to serve a simple content site. I chose the right tool for the job. If your Next.js site feels heavy for what it does, you owe it to yourself to try this migration.
[discussion]
Comments are powered by Giscus — backed by GitHub Discussions. Sign in with GitHub to join the conversation.