
Is Astro a good choice for content-heavy websites?
Yes, Astro is one of the best choices for content-heavy websites. It was designed specifically for content-driven sites such as blogs, documentation, news and magazine sites, knowledge bases and marketing pages. Pages are rendered to static HTML with no JavaScript by default, which keeps them fast and easy for search engines to read. On top of that, Astro has first-class tools for managing content: content collections that load Markdown, MDX, JSON, YAML or CMS data, validate it against a schema and give you typed queries, plus built-in image optimisation and official integrations for RSS, sitemaps and MDX.
The more content you have, the more those tools matter. A site with three pages can be built with anything; a site with three thousand articles needs validation, consistent URLs, fast builds and a way to bring in content from editors who never touch Git. In this article you'll learn what makes Astro suit content sites, how to set up a typed blog collection with images, how to generate pages, feeds and sitemaps, how CMS and live content fit in, how Astro handles scale, and where its limits are. Every example was built and tested with Astro 7.4.2.
What Content Sites Need
Before looking at Astro specifically, it helps to list what a content-heavy site actually requires:
- Fast first loads on every page, because most visitors arrive from search or social links and read one or two pages.
- Clean, crawlable HTML with proper headings, metadata and structured data.
- A content model: consistent frontmatter, required fields, dates, authors, tags and cover images.
- Authoring flexibility: Markdown for writers, components for rich layouts, a CMS for non-technical editors.
- Generated pages: listings, tag pages, pagination, feeds and sitemaps.
- Optimised media: responsive images that don't cause layout shift.
- Scalability: builds that stay manageable as the archive grows.
Astro addresses each of these directly, mostly with built-in features rather than plugins.
Static HTML by Default
An Astro article page is HTML and CSS. There's no framework runtime and no hydration step, so the page is readable as soon as it arrives. That's the right default for reading-focused pages and is the main reason Astro sites tend to perform well on Core Web Vitals without much tuning. You still have interactivity when you need it, using islands for a search box or a comments widget, as explained in how Astro ships zero JavaScript by default.
Content Collections: A Typed Content Model
Content collections are the feature that makes Astro feel purpose-built for content. Since Astro 6, collections are defined in src/content.config.ts using a loader that says where the content lives and an optional schema that says what shape it must have.
Defining a Blog Collection
Here's a blog collection that loads Markdown and MDX files, validates the frontmatter and supports an optional cover image:
// 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: ({ image }) =>
z.object({
title: z.string(),
description: z.string().max(170),
pubDate: z.coerce.date(),
cover: image().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
A few things are happening here:
- glob() loader: loads every matching file under
src/content/blogand gives each entry anidbased on its file name. - z from astro/zod: Astro's bundled Zod.
z.coerce.date()turns a frontmatter date string into aDate. - image() helper: resolves a relative image path and makes it available for optimisation.
- Defaults:
tagsanddraftare filled in when an author leaves them out.
An entry looks like this:
---
title: "My first post"
description: "Why I moved my blog to Astro."
pubDate: 2026-10-01
cover: "./cover.png"
tags: ["astro", "meta"]
---
## Hello
This post is written in **Markdown** and rendered at build time.
Validation Catches Mistakes at Build Time
If an author forgets a required field, the build fails with a clear message rather than publishing a broken page. Removing title from an entry produces:
[InvalidContentEntryDataError] blog → broken data does not match collection schema.
title**: **title: Required
The schema also generates TypeScript types, so post.data.pubDate is known to be a Date and your editor autocompletes every field. On a site with hundreds of authors' worth of content, this alone saves a lot of debugging. The schema options are covered in detail in how to define a collection schema with Zod.
Generating Pages From Content
Collections live outside src/pages/, so you create routes for them yourself. That gives you full control over URLs.
A Listing Page
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
---
<Base title="Blog">
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>{post.data.title}</a>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-GB')}
</time>
</li>
))}
</ul>
</Base>
getCollection() accepts a filter, which is how drafts are excluded. The sort is explicit because the order of a collection isn't guaranteed.
An Article Page
---
// src/pages/blog/[...id].astro
import { getCollection, render } from 'astro:content';
import { Image } from 'astro:assets';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { id: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content, headings } = await render(post);
---
<Base title={post.data.title}>
<article>
{post.data.cover && (
<Image src={post.data.cover} alt="" width={800} />
)}
<h1>{post.data.title}</h1>
<nav>
<ul>
{headings.map((h) => <li><a href={`#${h.slug}`}>{h.text}</a></li>)}
</ul>
</nav>
<Content />
</article>
</Base>
render() from astro:content returns a <Content /> component and a list of headings, which makes a table of contents trivial. The rest parameter ([...id]) means nested folders such as 2026/my-post.md work too. The build output for the first post is:
<article><img src="/_astro/cover.CzJmK1Kz_9nDuo.webp" alt loading="lazy" decoding="async" width="800" height="420"><h1>My first post</h1><nav><ul><li><a href="#hello">Hello</a></li></ul></nav><h2 id="hello">Hello</h2>
<p>This post is written in <strong>Markdown</strong> and rendered at build time.</p>
</article>
Notice the image: Astro converted the PNG to WebP, sized it to 800 pixels wide, calculated the height to preserve the aspect ratio and added lazy loading. Headings received id attributes automatically.
Markdown, MDX and the Sätteri Processor
Writers can use plain Markdown with GitHub-Flavored Markdown features (tables, task lists, footnotes, strikethrough) and smart punctuation, all enabled by default. Code blocks are highlighted by Shiki at build time, producing inline styles with no client-side JavaScript.
For richer articles, add the MDX integration with npx astro add mdx. MDX lets authors use components and expressions inside content:
---
title: "Using MDX"
description: "Components inside content."
pubDate: 2026-10-10
tags: ["astro"]
---
export const year = 2026;
## MDX works too
The year is {year}.
Since Astro 7, Markdown and MDX are rendered by Sätteri, a Rust-based compiler, rather than the remark and rehype pipeline. If you don't use remark or rehype plugins, nothing changes for you. If you do, either port them to Sätteri plugins or install @astrojs/markdown-remark and set markdown.processor: unified() in your config to keep using them.
Feeds and Sitemaps
Content sites need feeds and sitemaps, and both are official packages.
RSS
Install @astrojs/rss, set site in your config, and create an endpoint:
// src/pages/rss.xml.js
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return rss({
title: 'My Blog',
description: 'Notes on building for the web',
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/blog/${post.id}/`,
})),
});
}
The build writes dist/rss.xml with absolute links built from your site URL. More options, such as full content and custom XML, are in how to add an RSS feed to Astro.
Sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap()],
});
With site set, the build creates sitemap-index.xml and sitemap-0.xml listing every page, and splits into more numbered files for very large sites. Here's the build log for the example blog, including the image optimisation step:
generating static routes
├─ /blog/index.html (+6ms)
├─ /blog/first-post/index.html (+4ms)
├─ /blog/second-post/index.html (+4ms)
├─ /rss.xml (+22ms)
generating optimized images
▶ /_astro/cover.CzJmK1Kz_9nDuo.webp (before: 3kB, after: 0kB) (+96ms) (1/1)
[@astrojs/sitemap] `sitemap-index.xml` created at `dist`
[build] 3 page(s) built in 997ms
The draft post was excluded from the listing, the article routes, the feed and the sitemap, because every query used the same filter.
Content From a CMS
Many content teams don't write in Git. Astro handles that through loaders:
- Build-time loaders: a custom or community loader fetches entries from a CMS, database or API during the build and stores them in Astro's content layer. You then use
getCollection(),render()and schemas exactly as with local files. Loaders exist for many popular headless CMSs. - Live content collections: stable since Astro 6, these fetch data on each request through
getLiveCollection()andgetLiveEntry(). They suit frequently changing data and CMS previews, but don't support MDX or image optimisation and need an adapter. - Plain fetch: you can always
await fetch()in a page's component script and render the result.
Build-time collections are the default recommendation because they're cached and fast. Combine them with a webhook from your CMS that triggers a rebuild, or render specific pages on demand. See how to use Astro with a headless CMS for set-up patterns.
Does Astro Scale to Thousands of Pages?
Astro's documentation describes build-time collections as suitable for tens of thousands of entries, and collection data can be cached between builds so unchanged content isn't reprocessed. A few practices keep large sites manageable:
- Keep images in src/ so they're optimised, and use
<Image />with explicit widths. - Avoid heavy work per page in
getStaticPaths(); query once and pass entries as props. - Render rarely visited pages on demand if build time becomes a problem. With an adapter, set
export const prerender = falseon a route and fetch the entry withgetEntry()when it's requested. - Use route caching (
cacheandrouteRules, stable in Astro 7) or your host's CDN to cache on-demand pages.
Where Astro Is Less Suited
Astro is a framework, not a CMS. It doesn't provide an editing interface, user roles or scheduled publishing on its own; you pair it with a Git-based or headless CMS for that. Very frequent publishing on a fully static site means frequent rebuilds, which you solve with on-demand rendering, live collections or incremental strategies on your host. And if your "content site" is really an app with user accounts and dashboards at its core, read when you should not use Astro.
FAQ: Astro for Content Websites
Yes. It's one of the most common uses. Content collections validate your posts, render() turns Markdown into a component, and official packages add RSS, sitemaps and MDX. The official blog template gives you a working starting point.
Yes. Starlight, Astro's official documentation theme, adds navigation, search, internationalisation and accessible defaults on top of content collections, and many open-source projects use it.
Yes, if you connect a CMS. Use a Git-based CMS that commits Markdown to your repository, or a headless CMS with a content loader. Editors work in the CMS and a rebuild or on-demand page shows the changes.
You can, but they're no longer the default. Astro 7 renders Markdown with Sätteri. To keep unified plugins, install @astrojs/markdown-remark and set markdown.processor to unified() in your config.
Relative images in Markdown and images referenced through the image() schema helper are processed by Astro, which resizes them, converts formats and adds width and height attributes. Images in the public folder are copied as they are.
The build stops with an InvalidContentEntryDataError that names the collection, the entry and the field that failed, so broken content never reaches production.
Conclusion
Astro is a strong fit for content-heavy websites because it combines fast static HTML with tools designed for managing content. Content collections give you a schema, validation and types for every article; render() and dynamic routes turn entries into pages; and <Image />, RSS and the sitemap integration cover the practical needs of publishing.
As a site grows, loaders bring in content from a CMS, live collections handle data that can't wait for a rebuild, and on-demand rendering with route caching keeps build times in check. If your site exists mainly to be read, Astro gives you a structured, fast foundation with very little configuration.


