Astro 5.0: Static Site Generator That Actually Handles Dynamic Content
Our docs site was built on Next.js. It had 200 pages of Markdown content, and every page shipped 300KB of JavaScript because Next.js bundles React even for static pages. Lighthouse gave us a consistent 62 on mobile. After rebuilding on Astro 5.0, the same content ships 0KB of JavaScript by default. Lighthouse: 98. Here's how the rebuild went and what Astro 5.0's new features actually do in practice.
What's New in Astro 5.0
The three features that changed my build:
- Content Layer API. In Astro 4, content collections only read local Markdown files. In 5.0, the Content Layer can pull from any source — APIs, databases, CMS, remote Markdown. Your build isn't limited to what's in the repo.
- View Transitions. Animated page transitions that work without a SPA framework. The browser navigates between pages (full HTML) but transitions smoothly. No client-side router needed.
- Server Islands. Most of your page is static HTML, but specific components can be dynamically rendered on the server. Think: static blog post with a dynamic "related articles" section that updates based on current trending data.
Content Layer API for Real Data Sources
My docs come from three sources: local Markdown, a GitHub repo, and a headless CMS. Astro 5.0's Content Layer handles all three:
// src/content.config.ts
import { defineCollection, z } from "astro:content"
import { glob, file } from "astro/loaders"
import { githubLoader } from "./loaders/github"
// Local markdown docs
const docs = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./docs" }),
schema: z.object({
title: z.string(),
section: z.string(),
order: z.number(),
updated: z.date().optional(),
}),
})
// Remote GitHub wiki pages
const wiki = defineCollection({
loader: githubLoader({
repo: "myorg/myproject",
path: "wiki",
token: import.meta.env.GITHUB_TOKEN,
}),
schema: z.object({
title: z.string(),
html_url: z.string(),
}),
})
// Blog posts from Payload CMS
const blog = defineCollection({
loader: async () => {
const res = await fetch("https://cms.example.com/api/posts")
const posts = await res.json()
return posts.docs.map((post) => ({
id: post.slug,
title: post.title,
content: post.content,
publishedAt: post.publishedAt,
author: post.author.name,
}))
},
schema: z.object({
title: z.string(),
content: z.any(),
publishedAt: z.string(),
author: z.string(),
}),
})
export const collections = { docs, wiki, blog }
Then render any collection in a page:
// src/pages/docs/[...slug].astro
---
import { getCollection, render } from "astro:content"
export async function getStaticPaths() {
const docs = await getCollection("docs")
return docs.map((doc) => ({
params: { slug: doc.id },
props: { doc },
}))
}
const { doc } = Astro.props
const { Content } = await render(doc)
---
<Layout title={doc.data.title}>
<h1>{doc.data.title}</h1>
<Content />
</Layout>
Problem
Added <ViewTransitions /> to the layout. Pages transitioned smoothly but the scroll position jumped to the top before the new content appeared, causing a visible flicker. On slower connections, users saw a blank white flash for 200-400ms.
What I Tried
Tried adding transition:animate="fade" to individual elements. Tried disabling the fallback with fallback="none". The flicker got worse.
Actual Fix
The issue was that Astro's View Transitions replace the entire document body. The browser scrolls to top before the new content is painted. The fix is to use the transition:persist directive on shared layout elements and configure the transition properly:
// src/layouts/Base.astro
---
import { ViewTransitions } from "astro:transitions"
---
<html>
<head>
<ViewTransitions />
</head>
<body>
<nav transition:persist> {/* Keep nav across transitions */}
<!-- navigation -->
</nav>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>
// The transition:persist on nav prevents it from being swapped,
// eliminating the flash. The main content animates in smoothly.
View Transitions Without Flicker
Astro's View Transitions are based on the browser's native View Transitions API. The key insight: you don't need a SPA framework to get SPA-like transitions. Each page navigation is a full HTML load, but the browser swaps the DOM smoothly. The result feels like a React SPA but performs like static HTML.
Important: this only works in Chromium browsers natively. Firefox and Safari get a fallback (instant swap, no animation). The fallback is fine — it's the same behavior as a traditional multi-page app.
Server Islands for Mixed Static/Dynamic
This is the feature I needed most. Our docs site is 95% static content. But the sidebar shows "popular this week" links that need to be computed from analytics data. And the footer shows the latest version number from the GitHub API. Before Server Islands, I had to make the entire page dynamic for those two small parts.
// src/pages/docs/[...slug].astro
---
import { getCollection, render } from "astro:content"
import PopularLinks from "../components/PopularLinks"
import LatestVersion from "../components/LatestVersion"
// Static part — built at deploy time
const { doc } = Astro.props
const { Content } = await render(doc)
---
<Layout title={doc.data.title}>
<!-- This is static HTML, ships no JavaScript -->
<article>
<h1>{doc.data.title}</h1>
<Content />
</article>
<aside>
<!-- Server Island — rendered on the server at request time -->
<PopularLinks
client:only="server"
data-something="dynamic"
/>
</aside>
<footer>
<!-- Another Server Island for version info -->
<LatestVersion client:only="server" />
</footer>
</Layout>
// src/components/PopularLinks.astro
---
// This runs on the server at request time
const res = await fetch("https://analytics.example.com/popular-week")
const links = await res.json()
---
<nav>
<h3>Popular This Week</h3>
<ul>
{links.map((link) => <li><a href={link.url}>{link.title}</a></li>)}
</ul>
</nav>
The page loads instantly with the static content. The Server Islands stream in separately. Users see the docs content in under 100ms, and the dynamic parts fill in within 200-500ms.
Problem
The Content Layer caches API responses between builds. When I updated content in the CMS, ran a rebuild, and the old content showed up. The cache persisted for too long and there was no way to invalidate it.
What I Tried
Tried deleting .astro/ directory before each build. That worked but made builds 5x slower because all collections had to be re-fetched from scratch.
Actual Fix
Astro 5.0 supports per-collection cache invalidation. Set the refreshInterval in the loader config, or manually bust the cache for specific collections:
# Bust the entire content cache
rm -rf .astro/content
# Or use the CLI to rebuild a specific collection
npx astro build --collection blog --no-cache
// Or configure auto-refresh in the collection
const blog = defineCollection({
loader: cmsLoader({
url: "https://cms.example.com/api/posts",
refreshInterval: 60 * 5, // Refresh every 5 minutes during dev
}),
schema: z.object({ /* ... */ }),
})
What I Learned
- Astro is for content sites, not web apps. If you need complex client-side state, use Next.js. If your site is 90% content with some dynamic bits, use Astro.
- Zero JavaScript by default changes everything. Our Lighthouse went from 62 to 98. That's not a minor improvement — it's a different category.
- Content Layer API is Astro 5.0's best feature. Mixing local files, CMS content, and API data in one collection system is powerful.
- Server Islands solve the static/dynamic dilemma. You don't have to choose between fully static and fully dynamic anymore.
- View Transitions work but test on Safari. The fallback is instant swap, which is fine. Just don't build UX that depends on the animation.
Wrapping Up
Astro 5.0 is the best tool for content-heavy websites in 2026. The Content Layer API unifies all your data sources. Server Islands let you mix static and dynamic content without compromises. And zero JavaScript by default means your pages load fast everywhere. If you're building docs, blogs, or marketing sites, Astro should be your first choice.