We write Next.js code five days a week. It is the framework we reach for when building client applications -- dashboards, SaaS products, e-commerce platforms, anything with dynamic data and user authentication. We know it well. We trust it in production.
So when it came time to rebuild codercops.com, the assumption was obvious: Next.js. Instead, we chose Astro 5. And the results convinced us that framework selection based on what you know best is not the same as framework selection based on what the project needs.
This is the full story: the decision process, the performance data, the architectural tradeoffs, and an honest assessment of what we gained and what we gave up.
Choosing the right framework for the right job -- even when it means stepping outside your comfort zone
The Problem We Were Solving
The CODERCOPS website is, fundamentally, a content-heavy marketing site. Here is what it needs to do:
- Display service pages (static content, updated monthly)
- Host a technical blog (MDX, updated 2-3 times per week)
- Showcase portfolio/case studies (static, updated when projects ship)
- Provide contact forms (minimal interactivity)
- Load fast on Indian mobile networks (our primary audience)
- Score well on Core Web Vitals (client credibility)
Here is what it does not need to do:
- User authentication
- Real-time data
- Complex client-side state management
- Server-side data fetching on every request
- Session management
When we wrote it out like this, the answer became obvious. This is a content site with islands of interactivity. That is exactly what Astro was built for.
Why Not Next.js?
Let us be specific about the problems with Next.js for this type of site.
Problem 1: JavaScript Tax
Next.js ships a JavaScript runtime to every page by default. Even with the App Router and Server Components, the framework itself adds ~85-100KB of JavaScript to the initial bundle. For a blog post that is pure text and images, that is 100KB of JavaScript that does nothing useful.
Problem 2: Build Complexity
Our old Next.js site had:
next.config.jswith MDX configuration- Custom
contentlayersetup for blog posts - Workarounds for MDX in the App Router
- RSC serialization issues with certain MDX plugins
- Cold builds taking 45+ seconds
Problem 3: Hosting Cost
Next.js on Vercel is excellent, but features like ISR (Incremental Static Regeneration), middleware, and edge functions come with compute costs. For a site that changes twice a week, we were paying for infrastructure designed for dynamic applications.
Problem 4: Developer Experience Overhead
Every new blog post required understanding the Next.js rendering model. Is this page static? Server-side rendered? Which components are client components? The mental overhead was disproportionate to the complexity of the content.
Why Astro 5?
Astro's design philosophy is "ship less JavaScript." By default, Astro pages send zero JavaScript to the client. Interactive components are explicitly opted in via the island architecture. For a content-heavy site, this is exactly right.
Here is what Astro 5 brought to the table:
Zero JS by Default
---
// This is server-side only. No JS ships to the client.
const posts = await getCollection("blog");
const sorted = posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<html>
<head>
<title>CODERCOPS Blog</title>
</head>
<body>
<h1>Latest Posts</h1>
<ul>
{sorted.map((post) => (
<li>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
<time>{post.data.pubDate.toLocaleDateString()}</time>
</li>
))}
</ul>
</body>
</html>This page ships as pure HTML. Zero JavaScript. Zero framework runtime. The browser receives exactly what it needs: markup and styles.
Island Architecture for Interactivity
When we need JavaScript -- a contact form, a mobile navigation toggle, a search component -- we use Astro islands:
---
import ContactForm from "../components/ContactForm.tsx";
import MobileNav from "../components/MobileNav.tsx";
---
<html>
<body>
<!-- Static content: no JS -->
<header>
<nav class="desktop-nav">...</nav>
<!-- Interactive island: loads React, but only for this component -->
<MobileNav client:media="(max-width: 768px)" />
</header>
<main>
<h1>Contact Us</h1>
<p>Static text here, no JS needed.</p>
<!-- Interactive island: loads on page load -->
<ContactForm client:load />
</main>
</body>
</html>The client:media directive is particularly clever -- the mobile nav JavaScript only loads if the viewport matches. Desktop users get zero JS for navigation.
Native Content Collections
Astro 5's content collections are purpose-built for what we need:
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
author: z.string(),
image: z.string(),
tags: z.array(z.string()),
category: z.string(),
subcategory: z.string(),
featured: z.boolean().default(false),
}),
});
const services = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
icon: z.string(),
order: z.number(),
}),
});
export const collections = { blog, services };Type-safe, validated at build time, and MDX works out of the box. No third-party content layer. No configuration gymnastics.
View Transitions
Astro 5 includes built-in view transitions that make page navigation feel like a single-page application without shipping an SPA runtime:
---
import { ViewTransitions } from "astro:transitions";
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>This uses the browser's native View Transitions API. No JavaScript framework required.
Performance Comparison
We ran both sites through identical test conditions. Here are the numbers.
Bundle Size Comparison
| Metric | Next.js (App Router) | Astro 5 | Difference |
|---|---|---|---|
| Blog post page JS | 97 KB | 0 KB | -100% |
| Home page JS | 112 KB | 4.2 KB | -96% |
| Contact page JS | 134 KB | 18 KB | -87% |
| Total CSS | 42 KB | 38 KB | -10% |
| Blog post total transfer | 189 KB | 52 KB | -72% |
The blog post page is the most dramatic. With Astro, a blog post is HTML + CSS + images. That is it. No framework runtime, no hydration code, no router.
Core Web Vitals (Mobile, 4G Throttling)
| Metric | Next.js | Astro 5 | Target |
|---|---|---|---|
| LCP (Largest Contentful Paint) | 2.1s | 0.9s | < 2.5s |
| FID (First Input Delay) | 45ms | 8ms | < 100ms |
| CLS (Cumulative Layout Shift) | 0.04 | 0.01 | < 0.1 |
| TTI (Time to Interactive) | 3.2s | 1.1s | < 3.8s |
| TBT (Total Blocking Time) | 180ms | 12ms | < 200ms |
Both pass Core Web Vitals thresholds, but Astro is significantly faster on every metric. The LCP improvement is the most meaningful for SEO and user experience.
Build Time Comparison
| Metric | Next.js | Astro 5 |
|---|---|---|
| Cold build (120 pages) | 48s | 12s |
| Incremental build (1 page changed) | 8s | 2s |
| Dev server cold start | 6s | 1.5s |
| Dev server HMR | 400ms | 150ms |
Astro builds are 4x faster. For a blog where we push content multiple times per week, this adds up.
Lighthouse Scores (Average Across Pages)
| Category | Next.js | Astro 5 |
|---|---|---|
| Performance | 89 | 98 |
| Accessibility | 95 | 95 |
| Best Practices | 92 | 96 |
| SEO | 97 | 98 |
The Separate Content Repo Architecture
One architectural decision that has worked exceptionally well: we keep our blog content in a separate Git repository from the website code.
codercops-agency/ (website code)
├── src/
│ ├── components/
│ ├── layouts/
│ └── pages/
├── astro.config.mjs
└── package.json
codercops-agency-content/ (content only)
├── blog/
│ ├── post-one.mdx
│ ├── post-two.mdx
│ └── ...
├── services/
│ ├── ai-integration.mdx
│ └── ...
└── case-studies/
└── ...Why separate repos?
- Content writers do not need to understand the codebase. They push MDX files; the site rebuilds automatically.
- Faster CI. Content changes trigger a content-only build pipeline. Code changes trigger a full build.
- Independent versioning. We can roll back a blog post without rolling back a code deploy.
- Easier content management. Non-technical team members can edit content via GitHub's web interface.
Astro handles this seamlessly -- the content collection can point to a submodule or a synced directory.
Syncing Content at Build Time
Our build process pulls the content repo before building:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
repository_dispatch:
types: [content-update]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout content
uses: actions/checkout@v4
with:
repository: codercops/codercops-agency-content
path: content
token: ${{ secrets.CONTENT_REPO_TOKEN }}
- name: Sync content
run: |
rsync -av content/blog/ src/content/blog/
rsync -av content/services/ src/content/services/
- name: Build
run: npm run build
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}When a content author pushes to the content repo, a GitHub webhook triggers a repository_dispatch event on the main repo, which kicks off a rebuild.
Astro 5 Features We Use Daily
Hybrid Rendering
Astro 5 supports mixing static and server-rendered pages in the same project:
// astro.config.mjs
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
output: "hybrid", // static by default, opt-in to SSR
adapter: vercel(),
});---
// src/pages/blog/[slug].astro
// This page is static (pre-rendered at build time)
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
------
// src/pages/api/contact.ts
// This route is server-rendered
export const prerender = false;
export async function POST({ request }) {
const data = await request.formData();
// Handle form submission
// Send to CRM, email notification, etc.
}
---Most pages are static. The contact form handler and a few API routes are server-rendered. Astro makes this distinction explicit and clean.
Content Collection Querying
Querying content is intuitive and type-safe:
---
import { getCollection } from "astro:content";
// Get all published posts, sorted by date
const posts = (await getCollection("blog"))
.filter((post) => post.data.pubDate <= new Date())
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
// Get featured posts
const featured = posts.filter((post) => post.data.featured);
// Get posts by category
const aiPosts = posts.filter(
(post) => post.data.category === "AI Integration"
);
// Get unique tags across all posts
const allTags = [...new Set(posts.flatMap((post) => post.data.tags))];
---Image Optimization
Astro 5 has built-in image optimization that works at build time:
---
import { Image } from "astro:assets";
import heroImage from "../assets/hero.png";
---
<!-- Automatic optimization: WebP/AVIF, responsive sizes, lazy loading -->
<Image
src={heroImage}
alt="CODERCOPS hero"
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 400px, (max-width: 1200px) 800px, 1200px"
/>For external images (like Unsplash URLs in blog posts), we use the <Image> component with remote image support configured in astro.config.mjs.
What Next.js Is Still Better For
We are not anti-Next.js. It is the right choice for many projects. Here is our honest framework decision matrix:
| Requirement | Better Choice | Why |
|---|---|---|
| Content-heavy marketing site | Astro | Zero JS default, native MDX, faster builds |
| Blog / documentation | Astro | Content collections, build-time optimization |
| SaaS dashboard | Next.js | Auth, real-time data, complex client state |
| E-commerce with user accounts | Next.js | Server actions, middleware, session management |
| Landing pages | Astro | Performance, simplicity |
| Internal tools | Next.js | Rich interactivity, form handling |
| Portfolio / agency site | Astro | Performance-first, content-driven |
| Real-time collaborative app | Next.js | WebSocket integration, server-side state |
The rule we follow at CODERCOPS: If the page is primarily content that the user reads, use Astro. If the page is primarily an interface that the user interacts with, use Next.js.
For client projects, we recommend (and build with) Next.js about 80% of the time. The remaining 20% -- content sites, documentation portals, marketing pages -- we increasingly build with Astro.
The Migration Process
For teams considering a similar move, here is what the migration looked like:
Week 1: Project Setup and Layout Migration
- Set up Astro 5 project with TypeScript
- Port the design system (Tailwind config, component tokens)
- Create base layouts (header, footer, page shell)
- Estimated effort: 15 hours
Week 2: Content Migration
- Set up content collections with schemas
- Migrate all MDX blog posts (mostly copy-paste, minor frontmatter adjustments)
- Port the blog listing page, tag pages, category pages
- Estimated effort: 12 hours
Week 3: Page Migration and Interactivity
- Migrate service pages, about page, case studies
- Convert interactive components to Astro islands (React components with
client:loadorclient:visible) - Set up the contact form with server-rendered API route
- Estimated effort: 18 hours
Week 4: Optimization and Deployment
- Image optimization pipeline
- View transitions
- SEO metadata (Open Graph, structured data)
- Vercel adapter configuration
- Performance testing and comparison
- Estimated effort: 10 hours
Total estimated effort: 55 hours for a full migration. For a site with 100+ pages and a team familiar with both frameworks, this is roughly two developer-weeks.
Hosting on Vercel
Astro works seamlessly with Vercel's adapter:
npx astro add vercel// astro.config.mjs
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react";
export default defineConfig({
site: "https://codercops.com",
output: "hybrid",
adapter: vercel({
webAnalytics: { enabled: true },
imageService: true,
}),
integrations: [
mdx(),
sitemap(),
tailwind(),
react(), // For interactive islands
],
markdown: {
shikiConfig: {
theme: "github-dark",
},
},
});Static pages are served from Vercel's CDN. Server-rendered routes (API endpoints) run as serverless functions. The deployment is fast, and the cost is lower than our previous Next.js setup because most pages are purely static.
Deployment Cost Comparison
| Item | Next.js (Previous) | Astro 5 (Current) |
|---|---|---|
| Serverless function invocations | ~15K/month | ~2K/month |
| Edge middleware invocations | ~30K/month | 0 |
| Build time | ~50s | ~12s |
| ISR revalidations | ~500/month | 0 (static) |
| Monthly cost | ~$20 | ~$0 (free tier) |
The Astro site fits within Vercel's free tier because the vast majority of traffic is served as static assets from the CDN with no serverless compute required.
Lessons Learned
1. Framework choice should follow content type, not team familiarity. We knew Next.js better, but Astro was objectively the better tool for this job. The learning curve was mild -- maybe 2 days to become productive.
2. Zero JavaScript by default is transformative for content sites. Not "minimal JavaScript." Not "optimized JavaScript." Zero. The performance difference is not incremental; it is categorical.
3. The island architecture is the right mental model. Instead of thinking "everything is interactive, opt out of JavaScript where possible" (Next.js), you think "everything is static, opt in to JavaScript where necessary" (Astro). For content sites, the second model is simpler.
4. Separate content repos reduce friction. When publishing a blog post does not require running npm install or understanding a build pipeline, content velocity increases.
5. Astro is not a replacement for Next.js. It is a complement. We now use both frameworks regularly, choosing based on the project requirements. The industry trend toward framework-agnostic tooling (Tailwind, TypeScript, Vercel) makes this multi-framework approach practical.
The Bottom Line
If you are an agency running your marketing site on Next.js, React, or any SPA framework, and your site is primarily content, you are shipping more JavaScript than you need to. Astro 5 will give you measurably better performance with less code and lower hosting costs.
We are not saying everyone should switch. We are saying everyone should evaluate. Run the numbers for your specific site. Compare the Core Web Vitals. Look at your JavaScript bundle. If 80% of your pages are static content with no interactivity, Astro is worth serious consideration.
At CODERCOPS, we now recommend Astro for content-heavy client projects alongside our existing Next.js practice. The best framework is the one that matches what the project actually needs -- not the one you are most comfortable with.
Evaluating frameworks for your web project? CODERCOPS builds with both Next.js and Astro, choosing the right tool for each project's requirements. Talk to us about your next build.
Comments