Skip to content

Web Development · Engineering

Astro vs Next.js for Agency Websites: When Each One Fits

Pick Astro for sites people read and Next.js for apps people operate. A decision guide for agency and marketing sites, checked against Astro 7 and Next.js 16.

Anurag Verma

Anurag Verma

13 min read · Updated Oct 19, 2026

Astro 5 for Agency Websites: Why We Moved Away from Next.js for Our Own Site

Sponsored

Share

Agencies tend to pick a framework for their own site the same way they pick one for client work: whatever the team writes every day. For a lot of teams that is Next.js. It is a fine default for applications. For a marketing site with a blog, it is often the wrong one, and the reason has nothing to do with which framework is “faster” in general.

Web Development Choosing the framework that matches the job, not the one the team already knows

Astro or Next.js for an agency site: the short answer

Pick Astro when the site is mostly pages people read: service pages, a blog, docs, case studies. It renders to static HTML and ships no client JavaScript until you add an island. Pick Next.js when the site is an application people operate, with logins, carts, per-user data or heavy client state.

Most agency and marketing sites are the first kind. Some are a marketing front door bolted onto a product, and those are where the call gets interesting.

Where the two frameworks are in October 2026

Version facts move fast, and a lot of the Astro tutorials still ranking in search were written for Astro 4. Here is where things stand.

Astro 7.0 shipped on June 22, 2026. It moved to Vite 8 (with the Rust-based Rolldown bundler), replaced the Go-based .astro compiler with a Rust one, and made Sätteri, a Rust Markdown processor, the default for Markdown and MDX. The Astro 7 announcement calls the compiler “mostly backwards compatible”, with three exceptions worth knowing before you upgrade: it no longer corrects malformed HTML, unclosed tags are now errors, and whitespace between elements collapses the way JSX does it. Astro 7.4 followed on October 15, 2026. We cover the upgrade itself in Astro 7.0: the Rust compiler, Vite 8, and what breaks.

Next.js 16 shipped on October 21, 2025. It made Turbopack the default bundler for both next dev and next build, renamed middleware.ts to proxy.ts, and introduced Cache Components. Next.js 16.4, released October 6, 2026, recommends Cache Components for every app, turns them on by default in create-next-app, and says they become the default in Next.js 17. If you are still on 15, the Next.js 15 to 16 upgrade checklist covers the path.

When Astro fits

Astro’s model is static first. The islands docs put it plainly: components render to HTML and CSS, and Astro strips client-side JavaScript automatically. A component only becomes interactive when you add a client:* directive to it.

That default suits a site where most pages are text and images. Concretely, Astro is the better pick when:

  • the site is service pages, a blog, docs or case studies, updated by people who write Markdown or MDX
  • interactivity is local and small: a mobile menu, a contact form, a search box, a pricing toggle
  • nothing on the page depends on who is logged in
  • you care about Core Web Vitals on slow mobile connections and do not want to spend effort trimming a client bundle

You can still use React (or Vue, Svelte, Solid) for the interactive parts. That matters for an agency, because the components your team already wrote for client projects can live inside an Astro site as islands.

When Next.js fits

Next.js earns its runtime when the site behaves like software. Pick it when:

  • users sign in and see their own data
  • you need sessions, carts or checkout flows
  • large parts of the UI hold client state that survives navigation
  • you want Server Actions for mutations, or proxy.ts to handle requests before they hit a route
  • the marketing pages are a thin layer on top of a product that is already a Next.js app

The last case is the one I would think hardest about. Splitting a product into two frameworks has a real cost: two build pipelines, two sets of conventions, shared components that have to work in both. If the product is in Next.js and the marketing site is ten pages, keeping everything in one app is usually the saner choice. Next.js 16.4 made that easier with ensureStatic, a route segment export that fails the build if anything dynamic ends up in a route you meant to keep static:

// app/(marketing)/layout.tsx
export const ensureStatic = 'navigation';

export default function MarketingLayout({ children }) {
  return <>{children}</>;
}

The 16.4 post explicitly pitches this for “ecommerce stores, marketing pages, or blogs”. It does not remove the React runtime from the page, but it stops a stray request-time component from quietly turning a cheap static page into compute on every visit.

A decision table you can argue with

RequirementBetter choiceWhy
Content-heavy marketing siteAstroStatic HTML by default, JavaScript only where you add an island
Blog or documentationAstroContent collections with schema validation at build time
SaaS dashboardNext.jsAuth, per-user data, complex client state
E-commerce with accountsNext.jsServer Actions, sessions, request-time rendering
Landing pages for a campaignAstroLittle to hydrate, easy to host anywhere static
Internal toolsNext.jsRich interactivity and forms everywhere
Marketing pages inside a Next.js productNext.jsOne codebase; ensureStatic guards the static routes
Real time collaborative appNext.jsClient state and server integration dominate

If you want the broader three-way comparison with Remix, see Next.js 15 vs Astro 5 vs Remix.

Can a static Next.js export close the gap?

Partly. Next.js supports a full static export with output: 'export' in next.config, and the static exports docs say the build produces an out folder that any static host can serve. Server Components run at build time.

Two caveats. First, client navigation still goes through React: the docs say each route gets static HTML plus “a static payload for client navigation between routes”, and route transitions “behave like a traditional SPA”. So the browser still downloads and runs React. Second, a lot goes away in that mode. Cookies, rewrites, redirects, headers, proxy, ISR, Draft Mode, Server Actions and default image optimization are all on the unsupported list. For a pure brochure site that is fine. For a site that might grow a login later, it is a trap you will hit halfway through.

How Astro keeps pages light

A blog index in Astro is server-only code in the frontmatter and HTML in the template. None of it reaches the browser as JavaScript:

---
import { getCollection } from "astro:content";

const posts = (await getCollection("blog")).sort(
  (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---

<html>
  <head>
    <title>Blog</title>
  </head>
  <body>
    <h1>Latest posts</h1>
    <ul>
      {posts.map((post) => (
        <li>
          <a href={`/blog/${post.id}`}>{post.data.title}</a>
          <time>{post.data.pubDate.toLocaleDateString()}</time>
        </li>
      ))}
    </ul>
  </body>
</html>

Note post.id. The Astro 5 upgrade guide tells you to change references from slug to id, and a lot of copied snippets online still use post.slug.

When a piece of the page needs JavaScript, you mark it as an island and choose when it loads:

---
import ContactForm from "../components/ContactForm.tsx";
import MobileNav from "../components/MobileNav.tsx";
---

<html>
  <body>
    <header>
      <nav class="desktop-nav">...</nav>
      <!-- Hydrates only when the media query matches -->
      <MobileNav client:media="(max-width: 768px)" />
    </header>

    <main>
      <h1>Contact us</h1>
      <p>Static text, no JavaScript needed.</p>

      <!-- Hydrates as soon as the page loads -->
      <ContactForm client:load />
    </main>
  </body>
</html>

The directives reference lists the options. client:load hydrates immediately, client:idle waits until the page has finished its initial load, client:visible waits until the component scrolls into view, and client:media waits for a CSS media query. On a desktop visit, the mobile menu above never downloads its JavaScript. There is also server:defer, which turns a component into a server island rendered on demand, useful for something like a “logged in as” badge on an otherwise static page.

Content collections in current Astro

This is the part most likely to be out of date in whatever tutorial you are reading. Since Astro 5, build-time collections are defined in src/content.config.ts (not src/content/config.ts), each collection names a loader, and the content collections docs import z from astro/zod:

// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
  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(),
    featured: z.boolean().default(false),
  }),
});

const services = defineCollection({
  loader: glob({ pattern: "**/*.mdx", base: "./src/content/services" }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    order: z.number(),
  }),
});

export const collections = { blog, services };

Frontmatter that fails the schema fails the build, which is exactly what you want when non-developers are writing posts. MDX still needs the @astrojs/mdx integration.

Rendering a single post uses render() from astro:content:

---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";

export async function getStaticPaths() {
  const posts = await getCollection("blog");
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

Mixing static pages and server routes

Older posts (including the first version of this one) show output: "hybrid". That option is gone. Astro 5 merged hybrid into static, so the default build prerenders everything and you opt single routes out. The on-demand rendering docs say you need an adapter for any route rendered on request, and you mark the route with export const prerender = false:

// src/pages/api/contact.ts
import type { APIRoute } from "astro";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  const data = await request.formData();
  // validate, then forward to your CRM or email service
  return new Response(null, { status: 303, headers: { Location: "/thanks" } });
};

A config for a typical agency site, using the current adapter import and the Tailwind 4 Vite plugin (the styling docs say @astrojs/tailwind is only needed for Tailwind 3):

// astro.config.mjs
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  site: "https://example.com",
  adapter: vercel(),
  integrations: [mdx(), sitemap(), react()],
  vite: {
    plugins: [tailwindcss()],
  },
});

Only the contact route runs as a function. Everything else is files on a CDN, which is also why hosting a site like this tends to be cheap on any platform with a static tier.

View transitions without an SPA

Astro can make page changes feel like an SPA using the browser’s View Transitions API. The component was renamed in Astro 5: it is <ClientRouter /> now, not <ViewTransitions />.

---
import { ClientRouter } from "astro:transitions";
---

<html>
  <head>
    <ClientRouter />
  </head>
  <body>
    <main transition:animate="slide">
      <slot />
    </main>
  </body>
</html>

Adding <ClientRouter /> does ship a small client-side router, so it is a trade. On a blog I would leave it off unless the design really calls for animated transitions.

Keeping content in its own repository

One pattern that suits agency sites well, whichever framework you choose: put the Markdown and MDX in a separate Git repository from the site code. Writers push posts without touching the application, content changes can kick off their own build, and you can revert a post without reverting a code deploy. Non-technical editors can even use GitHub’s web editor.

site/                      (website code)
├── src/
│   ├── components/
│   ├── layouts/
│   └── pages/
├── astro.config.mjs
└── package.json

site-content/              (content only)
├── blog/
├── services/
└── case-studies/

The site build pulls the content repo before building. A minimal GitHub Actions version:

# .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: your-org/site-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 ci && npm run build

      # Deploy step for your host goes here

The content repo fires a repository_dispatch event at the site repo on push, and the site rebuilds. Since the glob loader takes a base path, you can point it at the synced folder without moving files around.

Measure your own site before you decide

The first version of this post had tables of bundle sizes, Lighthouse scores and build times. I removed them because they were not reproducible, and because someone else’s numbers on someone else’s pages tell you very little about yours. Here is a better way to settle it for a specific site:

  1. Open your heaviest content page in Chrome DevTools, filter the Network panel to JS, and note how much JavaScript loads on a page that has nothing interactive on it.
  2. Check field data, not just a lab run. Core Web Vitals are judged at the 75th percentile of real page loads, split by mobile and desktop.
  3. Track INP, not FID. INP replaced FID as a Core Web Vital on March 12, 2024, and web.dev counts 200 milliseconds or less as good and anything over 500 as poor. Our INP guide covers how to find the slow interactions.
  4. Prototype one template in Astro (usually the blog post page) and compare the same three numbers.

If the content pages already load little JavaScript and pass INP comfortably, the switch has little upside and you can stop here.

What a migration involves

The work splits into four chunks, and none of them is exotic:

  • Project setup and layouts: a new Astro project, your design tokens and Tailwind setup, the header, footer and page shell.
  • Content: collection schemas, then moving MDX files over. Expect frontmatter fixes, because the schema will reject anything inconsistent.
  • Pages and islands: service pages and case studies become .astro files, and interactive React components get a client:* directive instead of a rewrite.
  • Server routes and polish: the contact endpoint with prerender = false, an adapter, metadata, sitemap and redirects from old URLs.

The size depends on how much custom MDX tooling and how many interactive components the old site has, so I will not pretend there is a standard number of hours. The learning curve for a React developer is mild: .astro files are mostly HTML with a frontmatter script, and islands are ordinary React components. If you are staffing the work, the same profile you would look for in a Next.js developer will usually pick up Astro quickly. For a real production setup with SSR and a database, see Astro 5 in production, and for the Next.js side, our App Router guide.

The bottom line

Choose by what the pages do, not by what the team writes most often. A site people read should not make them download an application runtime to read it, and Astro’s defaults make that the easy path. A site people operate needs the things Next.js is built around, and Astro would make you rebuild them by hand.

If you are running a content site on Next.js today, measure before you move. Sometimes the answer is to switch frameworks. Sometimes it is ensureStatic and a smaller client bundle.


Evaluating frameworks for a web project? CODERCOPS builds with both Next.js and Astro. Talk to us about your next build.

Frequently asked questions

Is this an argument against Next.js?
No. Next.js is the better tool for authenticated products, e-commerce with sessions, dashboards and anything with real client-side state. The argument is narrower: a marketing site with a blog has none of those needs, so choosing Next.js for it because the team already knows Next.js means paying for a client runtime the pages do not use.
Can Next.js build a fully static marketing site?
Yes. Setting output: 'export' in next.config produces an out folder of HTML, CSS and JS that any static host can serve. Features that need a server, like cookies, rewrites, redirects, Server Actions, ISR and default image optimization, stop working in that mode. Client navigation still runs through React in the browser.
Which Astro version should I start a new site on?
The current line. Astro 7.0 shipped on June 22, 2026 with Vite 8, a Rust compiler and Sätteri as the default Markdown processor, and 7.4 followed on October 15, 2026. If you are on Astro 5 or 6, read an upgrade guide first, because the new compiler is stricter about unclosed tags and collapses whitespace between elements.
What do I give up by choosing Astro over Next.js?
Mostly the application features: Server Actions, proxy-based request handling, session-heavy per-request pages, and the React-everywhere model that makes complex client state straightforward. Astro can render routes on demand with an adapter and can host React components as islands, but a site that is mostly interactive UI will fight Astro's defaults.
Why keep blog content in a separate repository?
Writers can push MDX without touching application code, content changes can trigger their own build, and a post can be reverted without reverting a code deploy. The cost is a sync step at build time and a webhook between repos, which is less machinery than running a CMS.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored