Markdown is easy. Markdown at 500 articles is hard. The fix is to put the boring rules in place before article 50, not after: a strict frontmatter schema, a central component map, and a CI link checker. This guide ships the exact configs that keep a content site healthy at scale, updated for Astro 6 (released March 10, 2026), which removed the legacy Content Collections API entirely. If you copied a schema from a 2024 tutorial, it no longer builds.
TL;DR
- Use MDX if you embed any components; use plain Markdown if portability to a CMS matters more.
- On Astro 6 you must use the Content Layer API: collections live in
src/content.config.tsand require aloader(glob()for local files).type: 'content'and the oldsrc/content/config.tsare gone. - Validate every frontmatter field with a Zod schema. Add a
schemaVersionliteral so field renames are cheap. - Render with
render(entry)imported fromastro:content— the oldentry.render()instance method was removed in v6. - Run a build-time link checker and a brace scanner. LLM-assisted edits will eventually break MDX; catch it in CI, not in production.
What broke in Astro 6 (and why this matters now)
Astro 6 (stable, March 10, 2026) finished a migration that started in Astro 5: it removed automatic legacy content collections support and the legacy.collections flag. There is no backwards-compatibility fallback. Concretely, three things from older tutorials now error on build:
- The config file moved from
src/content/config.tstosrc/content.config.ts. type: 'content'(andtype: 'data') are deleted; every collection needs aloader.- Entries no longer have a
.render()method. You importrender()fromastro:contentand callrender(entry).
Astro 6 also drops Node 18 and 20 — you need Node 22.12.0 or newer. The payoff for migrating: the Content Layer API builds Markdown up to 5x faster and MDX about 2x faster, with 25–50% less memory, which is the difference between a 90-second and a 5-minute build once you cross a few thousand pages. (Source: Astro v6 upgrade guide.)
Markdown vs MDX: what each gives you
The first scaling decision is which format authors actually write in. They look similar; what they enable is very different.
Markdown (.md) | MDX (.mdx) | |
|---|---|---|
| Embedded components | No | Yes (Astro / React / Vue / Svelte islands) |
| Portable to Ghost / WP / Substack | Yes, directly | No — only MDX-aware builders |
| Grep / diff / lint friction | Low | Higher (JSX in the file) |
| Build pipeline required | No | Yes |
| LLM breakage risk | Low | Higher — unescaped { } fails the build |
| Build speed on Astro 6 | Fastest (~5x faster than v4) | ~2x faster than v4 |
One-line picker: if you only need typography, write Markdown. If you embed components in posts (callouts, FAQ blocks, interactive demos), write MDX. You can mix both in one collection — the glob() loader matches **/*.{md,mdx}.
How to tell you need a real schema
- You plan more than 100 articles.
- You expect to add or rename frontmatter fields over time.
- You want consistent components (callouts, code blocks, FAQ) across articles.
- You may translate or re-export content later.
If two or more apply, treat your content folder as a database, not a pile of text files. The schema below is the table definition.
Before you start
- Decide MDX vs Markdown first — schema and components depend on it.
- Pick a slug convention up front: kebab-case, lowercase, no dates in the slug.
- Make sure you are on Astro 6 with Node 22.12+. Run
astro infoto confirm.
Step by step
- Define a strict frontmatter schema with the Content Layer API. This goes in
src/content.config.ts(note: not the oldsrc/content/config.ts):
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const HUBS = ['ai-applications', 'ai-tools', 'indie-dev',
'prompt-library', 'troubleshooting'] as const;
const articles = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }),
schema: ({ image }) => z.object({
title: z.string().min(8).max(80),
description: z.string().min(80).max(170),
urlSlug: z.string().regex(/^[a-z0-9-]+$/),
category: z.enum(HUBS),
subcategory: z.string().optional(),
tags: z.array(z.string()).max(8),
publishedAt: z.date(),
updatedAt: z.date().optional(),
author: z.string().default('AI Productivity Guide Team'),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
lang: z.enum(['en', 'zh']),
translationKey: z.string(),
primaryKeyword: z.string().optional(),
hero: image().optional(), // image() helper for optimization
schemaVersion: z.literal(2).default(2),
}),
});
export const collections = { articles };
The loader: glob(...) line is the part that changed. The schemaVersion literal future-proofs the renames you will inevitably do.
- Centralize MDX components.
src/components/mdx/index.ts:
import Callout from './Callout.astro';
import FAQ from './FAQ.astro';
import VideoEmbed from './VideoEmbed.astro';
import { Image } from 'astro:assets';
export const mdxComponents = {
Callout,
FAQ,
VideoEmbed,
img: Image, // override default <img> with the optimized one
};
In the article layout, render with the new top-level render() function (the entry.render() method is gone in v6):
---
import { render } from 'astro:content';
import { mdxComponents } from '@/components/mdx';
const { Content } = await render(Astro.props.article);
---
<Content components={mdxComponents} />
Authors write <Callout type="warn">…</Callout> with no per-file imports.
- Pick one slug convention and enforce it. The schema regex
^[a-z0-9-]+$already blocksMy_Article-2024-01.mdx. Add a prebuild check that theurlSlugmatches the filename:
// scripts/check-slug-matches-filename.mjs
import { readdirSync, readFileSync } from 'node:fs';
import matter from 'gray-matter';
const dir = 'src/content/articles/en/indie-dev';
for (const file of readdirSync(dir)) {
const { data } = matter(readFileSync(`${dir}/${file}`, 'utf8'));
const expected = `${data.urlSlug}.mdx`;
if (file !== expected) {
console.error(`MISMATCH: ${file} != ${expected}`); process.exit(1);
}
}
- Store images in
src/assets/and use theimage()helper. Never reference content images from/public/...— you lose responsive sizing, AVIF/WebP conversion, and content-hashing:
---
hero: ../../assets/hero.jpg
---
import { Image } from 'astro:assets';
import diagram from '../../assets/diagram.png';
<Image src={diagram} alt="architecture diagram" widths={[400, 800, 1200]} formats={['avif', 'webp']} />
- Add a build-time internal-link checker. Fail the build on broken links so dead
[text](/en/articles/.../)references never ship:
// scripts/check-mdx-links.mjs (excerpt)
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const known = new Set(/* all slugs from frontmatter */);
let failed = false;
function walk(dir) {
for (const f of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, f.name);
if (f.isDirectory()) walk(full);
else if (f.name.endsWith('.mdx')) {
const md = readFileSync(full, 'utf8');
for (const m of md.matchAll(/\]\(\/[a-z]+\/articles\/([a-z0-9-]+)\/\)/g)) {
if (!known.has(m[1])) {
console.error(`BROKEN: ${full} -> ${m[1]}`); failed = true;
}
}
}
}
}
walk('src/content/articles');
if (failed) process.exit(1);
Wire it into package.json:
{
"scripts": {
"prebuild": "node scripts/audit-content.mjs && astro check && node scripts/check-mdx-links.mjs"
}
}
- Guard against the MDX brace bug. MDX parses
{...}in prose as a JSX expression, so a sentence likeset timeout to {value}fails the whole build withCould not parse expression with acorn. LLM-assisted edits hit this constantly. A CI scan that flags an opening brace followed by a letter, outside fenced code:
# fail build on unescaped braces in prose
awk 'BEGIN{f=0} /^```/{f=!f; next} {if(!f && /{[a-z]/) print FILENAME":"NR}' \
src/content/articles/**/*.mdx \
| tee /tmp/brace-hits.txt
test ! -s /tmp/brace-hits.txt
The fix in content is to wrap the token in backticks (`{value}`) or use a [value] placeholder. This guard catches it before deploy instead of after a red build.
- Document the editorial rules in
CONTENT.md. Authors should not reverse-engineer your schema from build errors. List the required fields, the slug rule, the component names, and the brace rule on one page.
Implementation checklist
- Content Layer collection in
src/content.config.tsvalidates every frontmatter field. - Every collection has a
loader(notype: 'content'). - MDX components are centralized; articles do not import them per file.
- Slug regex plus filename match enforced in prebuild.
- Internal link checker runs in CI and fails on dead links.
- Brace scan catches MDX gotchas before deploy.
- Node 22.12+ in CI and in your
.nvmrc.
After-launch verification
astro checkplus prebuild scripts pass on every PR.- A new article that violates the schema fails the build with a clear Zod error naming the field.
- Sitemap entries match the slugs declared in frontmatter.
- A migrated article still renders its components after the v6
render()change.
Common pitfalls
- Copying a
type: 'content'schema from an old tutorial — it will not build on Astro 6. - Leaving the config at
src/content/config.ts; Astro 6 only readssrc/content.config.ts. - Calling
entry.render()in the layout — replace withrender(entry)fromastro:content. - Letting MDX components be imported per file, so every article ends up styled slightly differently.
- Hard-coding image paths to
/public/...and losing responsive optimization. - Not versioning your frontmatter schema, so renaming a field silently breaks half your articles. The
schemaVersionliteral is the cheapest defense. - Skipping the brace scan — LLM-assisted edits will eventually fail the build on an unescaped
{.
FAQ
- MDX or plain Markdown?: MDX if you want any embedded components or interactive elements. Plain Markdown if portability and CMS compatibility matter more. You can keep both in one collection.
- Do I have to migrate off
type: 'content'?: Yes, if you are on Astro 6 (March 2026 or later). The legacy API and thelegacy.collectionsflag were removed with no fallback. Move config tosrc/content.config.ts, add aglob()loader, and switch torender(entry). - Should I commit images to git?: Yes for site-critical visuals in
src/assets/; use a CDN for heavy media. Committed assets keep the build reproducible and let theimage()helper hash them. - How do I keep frontmatter consistent across many authors?: Schema enforcement plus a documented
CONTENT.md. Reject PRs that failastro check. - What about internationalization?: Parallel folders like
src/content/articles/en/andsrc/content/articles/zh/, sharing one schema and atranslationKeyfield to pair translations. - How do I migrate when I rename a frontmatter field?: Bump
schemaVersion, write a one-off migration script inscripts/, run it, and commit the result in one PR.
Related
- Astro Content Collections — a 30-minute getting-started
- Astro image optimization in 2026
- Astro sitemap setup
- Building category and tag pages in Astro
- Astro SEO basics: title, meta, canonical, hreflang
- Content site section structure
- Run site content audit
Tags: #Indie dev #Astro #MDX #Content Collections #Content ops