Choose Theme

Why I Chose Astro Over Next.js (And Why You Might Too)

· 10 min read · #Astro #Architecture #Performance
--

Every developer conversation about web frameworks eventually turns into Next.js vs. [insert framework]. Next.js has won so decisively that choosing anything else feels like you need to justify it.

So here’s my justification: I chose Astro for this site, and I’d do it again.

This isn’t a Next.js hit piece. Next.js is excellent. But for content-heavy sites with selective interactivity, Astro is often the better choice. Here’s why.

The Decision Context

What I was building:

  • Personal website with blog and photo gallery
  • Mostly static content with occasional interactivity
  • Real-time features on specific pages only
  • Excellent performance and SEO as priorities
  • No complex data fetching or server state

What I considered:

  • Next.js (App Router)
  • Next.js (Pages Router)
  • Astro
  • Remix
  • SvelteKit

I built prototypes in Next.js and Astro. Astro won. Here’s the breakdown.

Round 1: Default JavaScript Bundle

Next.js Approach

app/page.tsx
export default function Home() {
return (
<div>
<h1>Welcome</h1>
<p>This is a static page with no interactivity.</p>
</div>
);
}

JavaScript shipped: ~85 KB gzipped (React runtime + Next.js + hydration)

Astro Approach

src/pages/index.astro
---
// No runtime needed for static content
---
<div>
<h1>Welcome</h1>
<p>This is a static page with no interactivity.</p>
</div>

JavaScript shipped: 0 KB

Winner: Astro

For pages with zero interactivity, shipping React is wasteful. Most blog posts don’t need JavaScript.

ℹ️
Note

Yes, you can optimize Next.js bundles. But with Astro, zero JavaScript is the default, not something you fight for.

Round 2: Partial Hydration

Say you want one interactive component on an otherwise static page.

Next.js Approach

app/page.tsx
import { LikeButton } from './LikeButton';
export default function BlogPost() {
return (
<article>
<h1>My Post</h1>
<p>Lots of static content...</p>
<LikeButton /> {/* Hydrates React for entire page */}
</article>
);
}

JavaScript shipped: ~85 KB + your component code

Astro Approach

src/pages/blog/post.astro
---
import LikeButton from '../../components/LikeButton.astro';
---
<article>
<h1>My Post</h1>
<p>Lots of static content...</p>
<LikeButton client:visible /> <!-- Only hydrates this component -->
</article>

JavaScript shipped: ~3 KB (just LikeButton, no framework)

Winner: Astro

Astro’s Islands Architecture shines here. You get granular control over what hydrates and when.

<!-- Load immediately -->
<Component client:load />
<!-- Load when visible -->
<Component client:visible />
<!-- Load when idle -->
<Component client:idle />
<!-- Load on media query -->
<Component client:media="(max-width: 768px)" />
<!-- Never hydrate, just render HTML -->
<Component />

This level of control is what Next.js Server Components aspire to, but Astro nailed it years earlier.

Round 3: Content Collections

Both frameworks handle MDX, but the experience differs.

Next.js (App Router)

app/blog/[slug]/page.tsx
import fs from 'fs';
import path from 'path';
import { compileMDX } from 'next-mdx-remote/rsc';
export default async function BlogPost({ params }) {
const filePath = path.join(process.cwd(), 'content', `${params.slug}.mdx`);
const source = fs.readFileSync(filePath, 'utf8');
const { content, frontmatter } = await compileMDX({
source,
options: { /* ... */ }
});
return (
<article>
<h1>{frontmatter.title}</h1>
{content}
</article>
);
}
export async function generateStaticParams() {
// Manually read content directory
const files = fs.readdirSync(path.join(process.cwd(), 'content'));
return files.map(filename => ({
slug: filename.replace('.mdx', '')
}));
}

Lots of manual file system operations. Feels like fighting the framework.

Astro Content Collections

src/pages/blog/[...slug].astro
---
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>

Winner: Astro

Content Collections feel purpose-built for MDX blogs. Type-safe frontmatter, automatic slug generation, built-in sorting—it all just works.

💡
Tip

Astro’s Content Collections validate frontmatter at build time with Zod schemas. Catch mistakes before deploy.

Round 4: Build Times

This site has:

  • 31 photos (optimized to 124 files)
  • 4 blog posts
  • 8 snippets
  • 5 static pages

Next.js Build

Terminal window
> next build
Route (app) Size First Load JS
/ 142 B 87.2 kB
/blog 1.2 kB 88.3 kB
/blog/[slug] 1.5 kB 88.8 kB
/blog/post-1
/blog/post-2
...
(Static) prerendered as static content
(SSG) prerendered as static HTML (uses getStaticProps)
Done in 8.4s

Astro Build

Terminal window
> astro build
11:23:42 [build] output: "static"
11:23:42 [build] directory: /dist/
11:23:42 [build] Collecting build info...
11:23:42 [build] ✓ Completed in 1.89s.
11:23:42 [build] Building static entrypoints...
11:23:43 [build] ✓ Completed in 421ms.
11:23:43 [build] Generating static routes...
11:23:43 [build] ▶ src/pages/index.astro
11:23:43 [build] └─ /index.html (+15ms)
...
Done in 2.3s

Winner: Astro (3.6x faster)

Astro builds are consistently faster. No React runtime to bundle, fewer optimization passes, simpler architecture.

Round 5: Adding Real-Time Features

I wanted live cursors and chat on certain pages. Both frameworks support this, but the DX differs.

Next.js Client Component

components/RealtimeCursors.tsx
'use client'; // Required for interactivity
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
export function RealtimeCursors() {
const [cursors, setCursors] = useState([]);
useEffect(() => {
const channel = supabase.channel('cursors');
// ... realtime logic
}, []);
return (
<div>
{cursors.map(cursor => (
<Cursor key={cursor.id} {...cursor} />
))}
</div>
);
}

Works fine, but every page using this component now hydrates React—even if the rest of the page is static.

Astro Island

components/RealtimeCursors.astro
<div id="cursors-container"></div>
<script>
import { supabase } from '../lib/supabase';
const channel = supabase.channel('cursors');
// ... realtime logic (vanilla JS)
</script>

Or use a framework component with selective hydration:

<RealtimeCursors client:visible />

Winner: Astro

Islands let you add interactivity without hydrating the entire page. Perfect for real-time features that should only load when needed.

Round 6: Developer Experience

File Routing

Both use file-based routing, but Astro feels more intuitive:

src/pages/
├── index.astro → /
├── about.astro → /about
├── blog/
│ ├── index.astro → /blog
│ └── [slug].astro → /blog/:slug
└── api/
└── views.ts → /api/views

vs. Next.js App Router:

app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug
└── api/
└── views/
└── route.ts → /api/views

The nested page.tsx pattern feels repetitive. Astro’s .astro = page convention is cleaner.

Frontmatter vs Exports

Next.js:

export const metadata = {
title: 'My Page',
description: 'Description here'
};
export default function Page() {
return <div>Content</div>;
}

Astro:

---
const title = 'My Page';
const description = 'Description here';
---
<div>Content</div>

I prefer Astro’s frontmatter. It separates data from markup cleanly.

Winner: Tie (personal preference)

Round 7: Performance

Real-world Lighthouse scores for this site:

Astro Version (current)

  • Performance: 99/100
  • Accessibility: 100/100
  • Best Practices: 100/100
  • SEO: 100/100
  • First Contentful Paint: 0.6s
  • Largest Contentful Paint: 0.8s
  • Total Blocking Time: 0ms
  • Cumulative Layout Shift: 0
  • Speed Index: 0.9s

Next.js Version (prototype)

  • Performance: 92/100
  • Accessibility: 100/100
  • Best Practices: 100/100
  • SEO: 100/100
  • First Contentful Paint: 0.8s
  • Largest Contentful Paint: 1.2s
  • Total Blocking Time: 30ms
  • Cumulative Layout Shift: 0
  • Speed Index: 1.3s

Winner: Astro (7 points higher, 400ms faster LCP)

The React runtime adds measurable overhead. For content sites, it’s unnecessary.

When Next.js Wins

Astro isn’t always the answer. Next.js is better when:

1. Heavy Interactivity

If your site is app-like (dashboards, SaaS apps, rich interactions), Next.js wins.

// This makes sense in Next.js
export default function Dashboard() {
const [data, setData] = useState([]);
const [filters, setFilters] = useState({});
const [sorting, setSorting] = useState('asc');
// Tons of client-side logic
return <ComplexDataTable data={data} />;
}

Astro can do this, but you’re fighting the framework. Next.js embraces it.

2. Server-Side Rendering (SSR)

Astro recently added SSR, but Next.js is more mature:

// Next.js SSR is battle-tested
export default async function Page({ params }) {
const data = await fetchUserData(params.id);
return <Profile data={data} />;
}

If you need dynamic SSR for every request, Next.js has better DX.

3. API Routes with Complex Logic

Next.js API routes feel more natural for backend-heavy apps:

app/api/users/[id]/route.ts
export async function GET(req: Request, { params }) {
const user = await db.users.findById(params.id);
return Response.json(user);
}
export async function PATCH(req: Request, { params }) {
const body = await req.json();
const updated = await db.users.update(params.id, body);
return Response.json(updated);
}

Astro endpoints work, but Next.js feels more like Express.

4. React Ecosystem Lock-In

If you’re heavily invested in React (component libraries, custom hooks, team expertise), switching to Astro adds friction.

5. Vercel Hosting

Next.js on Vercel is magical—one-click deploy, automatic preview URLs, edge functions. It just works.

Astro works on Vercel too, but it’s optimized for Cloudflare/Netlify.

ℹ️

Next.js is built by Vercel. If you’re on Vercel anyway, Next.js is the path of least resistance.

When Astro Wins

Astro shines for:

1. Content-Heavy Sites

Blogs, marketing sites, documentation, portfolios—anything where content > interactivity.

2. Performance-Critical Projects

If you’re chasing perfect Lighthouse scores or optimizing for slow networks, Astro’s zero-JS default is unbeatable.

3. Multi-Framework Projects

Astro lets you mix React, Vue, Svelte, Solid in one project:

---
import ReactComponent from './ReactComponent.jsx';
import VueComponent from './VueComponent.vue';
import SvelteComponent from './SvelteComponent.svelte';
---
<div>
<ReactComponent client:load />
<VueComponent client:visible />
<SvelteComponent client:idle />
</div>

Migrate incrementally or use the best tool for each component.

4. Learning Web Fundamentals

Astro feels closer to HTML/CSS/JS than React abstractions. Great for learning or teaching.

5. You Want Simple

Astro is easier to reason about:

  • .astro files = pages
  • Frontmatter = data
  • Islands = interactivity

No mental model of Server Components vs Client Components.

My Specific Use Case

For this site, Astro was the right choice:

Content-focused - Blog posts, photos, snippets ✅ Selective interactivity - Real-time features on some pages ✅ Performance priority - Perfect Lighthouse scores ✅ Simple architecture - Easy to understand and maintain ✅ No heavy state management - Static content with occasional API calls

Next.js would work, but I’d be fighting defaults:

  • Shipping React to render markdown
  • Optimizing bundles to reduce JavaScript
  • Server Components mental overhead
  • Unnecessary complexity for static pages

Migration Path

Already on Next.js? You don’t have to rewrite everything:

  1. Keep Next.js for app pages - Dashboards, interactive tools
  2. Move content to Astro - Blog, docs, marketing
  3. Share components - Astro can import React components
  4. Run both - Astro at /blog, Next.js at /app

Hybrid approach is totally viable.

The Real Question

Not “Which is better?” but “Which is better for your project?”

Ask yourself:

  1. Is your site mostly content or mostly app?

    • Content → Astro
    • App → Next.js
  2. Do you need SSR for every request?

    • Yes → Next.js
    • No → Astro
  3. Is performance critical?

    • Yes → Astro
    • Nice to have → Either
  4. Are you deeply invested in React?

    • Yes → Next.js
    • No → Either
  5. Do you prioritize simplicity?

    • Yes → Astro
    • Doesn’t matter → Either

Conclusion

I chose Astro because:

  • Zero JavaScript by default - Only add what you need
  • Islands Architecture - Granular hydration control
  • Built for content - MDX and Content Collections feel native
  • Faster builds - 2-3x faster than Next.js for this site
  • Better performance - 99/100 Lighthouse without optimization
  • Simpler mental model - Easier to reason about

Next.js is incredible for app-like experiences. But for content-focused sites with selective interactivity, Astro is often the better tool.

Your mileage will vary. Build a prototype in both. See which feels right for your project.

For me, Astro felt right. Three months later, no regrets.

ℹ️
Note

This isn’t about tribalism. Use whatever works for you. Both frameworks are excellent at what they do.

Related