Building a Markdown / MDX Content Site That Scales

Structure a Markdown / MDX content site to scale from 50 to 1000+ articles on Astro 6. Content Layer schema, component map, link checker, and the brace gotcha.

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.ts and require a loader (glob() for local files). type: 'content' and the old src/content/config.ts are gone.
  • Validate every frontmatter field with a Zod schema. Add a schemaVersion literal so field renames are cheap.
  • Render with render(entry) imported from astro:content — the old entry.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.ts to src/content.config.ts.
  • type: 'content' (and type: 'data') are deleted; every collection needs a loader.
  • Entries no longer have a .render() method. You import render() from astro:content and call render(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 componentsNoYes (Astro / React / Vue / Svelte islands)
Portable to Ghost / WP / SubstackYes, directlyNo — only MDX-aware builders
Grep / diff / lint frictionLowHigher (JSX in the file)
Build pipeline requiredNoYes
LLM breakage riskLowHigher — unescaped { } fails the build
Build speed on Astro 6Fastest (~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 info to confirm.

Step by step

  1. Define a strict frontmatter schema with the Content Layer API. This goes in src/content.config.ts (note: not the old src/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.

  1. 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.

  1. Pick one slug convention and enforce it. The schema regex ^[a-z0-9-]+$ already blocks My_Article-2024-01.mdx. Add a prebuild check that the urlSlug matches 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);
  }
}
  1. Store images in src/assets/ and use the image() 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']} />
  1. 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"
  }
}
  1. Guard against the MDX brace bug. MDX parses {...} in prose as a JSX expression, so a sentence like set timeout to {value} fails the whole build with Could 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.

  1. 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.ts validates every frontmatter field.
  • Every collection has a loader (no type: '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 check plus 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 reads src/content.config.ts.
  • Calling entry.render() in the layout — replace with render(entry) from astro: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 schemaVersion literal 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 the legacy.collections flag were removed with no fallback. Move config to src/content.config.ts, add a glob() loader, and switch to render(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 the image() helper hash them.
  • How do I keep frontmatter consistent across many authors?: Schema enforcement plus a documented CONTENT.md. Reject PRs that fail astro check.
  • What about internationalization?: Parallel folders like src/content/articles/en/ and src/content/articles/zh/, sharing one schema and a translationKey field to pair translations.
  • How do I migrate when I rename a frontmatter field?: Bump schemaVersion, write a one-off migration script in scripts/, run it, and commit the result in one PR.

Tags: #Indie dev #Astro #MDX #Content Collections #Content ops