Markdown & MDX
Markdown as pages
Section titled “Markdown as pages”Drop a .md file into src/pages/ and it becomes a route, rendered to HTML — the same file-based routing as .astro. It is the fastest way to author content.
---title: Hello World---
# Hello World
This is **Markdown** with a `code span` and a [link](/x).
- item one- item twoAt the top is frontmatter — a YAML block delimited by --- — holding metadata like title or date. The Markdown body itself renders to plain HTML.
Applying a layout to Markdown
Section titled “Applying a layout to Markdown”Raw Markdown renders as a bare HTML fragment with no shell. To wrap it in your site chrome, point the layout frontmatter key at a layout component:
---layout: ../../layouts/BaseLayout.astrotitle: Hello World---
Content here goes into the layout's default slot.The layout receives the page’s frontmatter as props (via Astro.props.frontmatter), and the rendered Markdown fills the layout’s default <slot />:
---const { frontmatter } = Astro.props;---<html> <head><title>{frontmatter.title}</title></head> <body> <article><slot /></article> <!-- the Markdown body lands here --> </body></html>Proven end to end with a real build of exactly this pair — the layout’s Astro.props.frontmatter correctly supplies the <title>, even though (as proven above) the Markdown body itself never evaluates { }:
<html><head><title>Hello Layout</title></head><body><article><p>Body content here.</p></article></body></html>MDX: Markdown plus real JavaScript
Section titled “MDX: Markdown plus real JavaScript”.mdx is Markdown with JSX-like power, enabled by the @astrojs/mdx integration. Unlike plain .md, .mdx genuinely evaluates { } expressions, accepts exported variables, and lets you import and render components right inside your prose:
---title: Hello MDX---export const revision = 3;
# {frontmatter.title}
Revision: {revision}Proven with a real build — both the frontmatter interpolation and the exported variable evaluate correctly, in direct contrast to the plain-.md proof above:
<h1 id="hello-mdx">Hello MDX</h1><p>Revision: 3</p>Importing and rendering a component works the same way, including islands with client directives — Astro or framework components, since MDX composes with everything else in Astro:
---title: Release noteslayout: ../../layouts/BaseLayout.astro---import Callout from '../../components/Callout.astro';import Chart from '../../components/Chart.jsx';
# {frontmatter.title}
Regular Markdown paragraphs work as usual.
<Callout title="Heads up"> You can drop a component into the middle of prose.</Callout>
And even an interactive island:
<Chart client:visible data={[1, 2, 3]} />A <Fragment> works too, and adds no wrapper element to the surrounding prose — proven with a real build (frontmatter/exported-variable output from above, immediately followed by the Fragment’s own content, no extra tags around it):
<Fragment> <p>a fragment inside prose</p></Fragment><p>a fragment inside prose</p>flowchart LR md["Markdown or MDX file"] --> fm["frontmatter provides metadata"] fm --> layout["layout key wraps it in a shell"] md --> body["body renders to HTML"] body --> comp["MDX can also embed Astro and framework components"] layout --> out["final page"] comp --> out
The <Content /> component and its components prop
Section titled “The <Content /> component and its components prop”Importing Content (and any exported values) from an .mdx file lets an .astro file render that file’s full output — and the components prop remaps which component renders which Markdown element, without touching the .mdx file itself:
export const title = 'Custom Content';
# {title}
Some *body* text with a custom heading renderer.---import { Content } from './content-comp.mdx';---<Content components={{ h1: 'h2' }} />Proven with a real build — the exported title still evaluates, and the # ... heading, which would normally compile to an <h1>, renders as an <h2> instead because of the components remap:
<h2 id="title">Custom Content</h2><p>Some <em>body</em> text with a custom heading renderer.</p>This is how a design system enforces its own heading component (with consistent anchor-link styling, say) across every Markdown/MDX file, without every author having to import it themselves.
Astro 7 renders Markdown with Sätteri — verified against the config schema
Section titled “Astro 7 renders Markdown with Sätteri — verified against the config schema”Since Astro 7, .md and .mdx files are rendered by Sätteri, Astro’s native, Rust-powered Markdown pipeline, instead of the JavaScript remark/rehype (unified) pipeline by default. For plain content nothing changes except speed — it is one of the reasons Astro 7 builds are 15–61% faster (source: Astro 7.0 release notes).
Where it matters is plugins — and precisely how you keep them working changed. Checked directly against the installed [email protected] config type schema, markdown.remarkPlugins, markdown.rehypePlugins, markdown.gfm, and markdown.smartypants are all marked @deprecated, each pointing at the same replacement: pass them to the configured markdown.processor instead. markdown.shikiConfig and markdown.syntaxHighlight are not deprecated — code-block syntax highlighting is a separate layer that applies regardless of which Markdown processor renders the surrounding prose.
import { defineConfig } from 'astro/config';import { unified } from '@astrojs/markdown-remark';import remarkToc from 'remark-toc';
export default defineConfig({ markdown: { // Correct for 7.3.5: plugins are arguments to unified(), not sibling // top-level markdown.* keys (those are the deprecated form). processor: unified({ remarkPlugins: [remarkToc], }), },});// @expect-error deprecated in [email protected] — remarkPlugins/rehypePlugins as top-level markdown.* keys still work but are marked @deprecated in the installed config schema; pass them to unified() instead (shown above)import { defineConfig } from 'astro/config';import { unified } from '@astrojs/markdown-remark';
export default defineConfig({ markdown: { processor: unified(), remarkPlugins: [/* ... */], rehypePlugins: [/* ... */], },});@astrojs/markdown-remark is a separate install (so is @astrojs/markdown-satteri, if you want to configure Sätteri’s own options, like features: { gfm: false }, explicitly rather than relying on its defaults). The rule of thumb: start with Sätteri (the default, no install needed) and only switch the processor when a plugin you actually need has no native equivalent.
.md versus .mdx
Section titled “.md versus .mdx”Reach for .md when the file is pure content — a blog post, a doc page, notes. It is simpler, has no component overhead, and — proven above — its body is plain text plus Markdown syntax only, no expressions.
Reach for .mdx when the content needs to embed components or evaluate real JavaScript — a callout box, a chart, an interactive demo, an exported variable, frontmatter interpolated directly into prose (proven above, in contrast to .md). The cost is the extra integration, slightly heavier processing, and a stricter parser — proven next — so do not default everything to MDX; use it where you actually mix content, components, and expressions.
MDX pitfalls, proven with real build errors
Section titled “MDX pitfalls, proven with real build errors”MDX’s body isn’t just Markdown — the @astrojs/mdx integration parses it with a JSX-aware parser, so ordinary prose can accidentally look like syntax. Three real failures, reproduced against a real build:
1. A bare { starting an object-literal-looking expression in prose:
// @expect-error a `{` in prose is parsed as an MDX expression, and this one isn't valid JSSet the value like this: { port: 3000 } in your config.3:32: Could not parse expression with oxc: Expected `,` or `)` but found `:` (mdx-jsx:unexpected-character)2. A line that starts with the word import (even as ordinary prose):
// @expect-error a line beginning with "import" is parsed as the start of an ESM import statementimport your data from wherever you like.3:13: Could not parse esm with oxc: Expected `from` but found `Identifier` (mdxjs-rs:oxc)3. A generic-looking type reference in prose, like Array<T>:
// @expect-error `<T>` looks like a JSX opening tag with no matching closeA generic function like Array<T> takes a type parameter.3:30: Expected a closing tag for `<T>` (3:30) before the end of `paragraph` (mdx-jsx:unexpected-character)Each is fixable the same way: put the offending text inside an inline code span (`{ port: 3000 }`, `import`, `Array<T>`) — code spans are inert text, never parsed as expressions or JSX, exactly like the { escape shown in the syntax-deep lesson’s is:raw section, but automatic for anything already inside backticks.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a generated
.mdfile’s body contain{frontmatter.something}or another{ }expression, copied from an MDX example? Proven above: in plain.mdit renders as literal, useless text — either convert the file to.mdxor move the value into the layout. - Does a generated
astro.config.mjssetmarkdown.remarkPluginsormarkdown.gfmas a top-level key? Proven above (checked against the installed config schema): those are deprecated in [email protected] — pass them tounified()viamarkdown.processorinstead, or they may silently stop applying in a future major version. - Is a remark/rehype-plugin-based config copied from an older (pre-7) Astro blog post assumed to “just work”? Sätteri is the new default — a project that needs those exact plugins needs the explicit
processor: unified({ ... })opt-in, proven above. - Does MDX prose contain a raw
{, a line starting withimport/export, or a<Word>-shaped generic type reference outside a code span? Proven above with three distinct real parse errors — each needs to be inside backticks or otherwise escaped.
🐛 Spot the bug
---title: My Post---
# {frontmatter.title}
Written on { new Date().toDateString() }.Show the answer
This is a plain .md file (no .mdx extension) using MDX-only syntax. Proven earlier in this lesson: a plain Markdown body never evaluates { } — both the heading and the “Written on” line would render their curly-brace expressions as literal, useless text ({frontmatter.title}, { new Date().toDateString() }), not the page’s title or the current date. The fix is either to rename the file to .mdx (which does evaluate these), or, if the content should stay plain Markdown, to move the dynamic values into the layout component the page uses via the layout frontmatter key, referencing Astro.props.frontmatter there instead.
The BaseLayout from this lesson (an ordinary, collectible .astro file) is fully covered by a real container test, proving the other half of “applying a layout to Markdown” — that it receives frontmatter as props and fills its default slot with the body:
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { describe, it, expect } from 'vitest';import BaseLayout from './BaseLayout.astro';
describe('layout receives frontmatter as props', () => { it('renders the title from Astro.props.frontmatter and fills the default slot with the body', async () => { const container = await AstroContainer.create(); const html = await container.renderToString(BaseLayout, { props: { frontmatter: { title: 'Hello Layout' } }, slots: { default: '<p>Body content here.</p>' }, }); expect(html).toContain('<title>Hello Layout</title>'); expect(html).toContain('<article'); expect(html).toContain('<p>Body content here.</p>'); });});A note on content collections
Section titled “A note on content collections”Putting Markdown files directly in src/pages/ works, but it does not scale to a large blog or docs site: there is no schema validation, no typed frontmatter, no querying. The modern way to manage many Markdown/MDX files is content collections — a typed, validated, queryable content layer — which is the entire focus of the next module. Loose pages are fine for a handful of files; collections are how you run real content sites.
Mental Model
Section titled “Mental Model”A .md file’s body is Markdown and nothing else — no expressions, no imports, no components; only its YAML frontmatter and, through a layout, that frontmatter’s values ever reach dynamic behavior. An .mdx file’s body is Markdown plus a real embedded JS/JSX language — proven above, { } evaluates, export/import work, components render — which is exactly why its parser is stricter about anything that merely looks like that syntax appearing in ordinary prose.
Trade-offs
Section titled “Trade-offs”.md |
.mdx |
|
|---|---|---|
| Body is | Markdown only — { } is inert text (proven above) |
Markdown + JSX-aware — { } evaluates, export/import work (proven above) |
| Components | Not supported in the body | Import and render Astro/framework components, including islands |
| Prose hazards | None beyond Markdown syntax itself | A bare {, a line starting with import/export, or <Tag>-shaped text can break the build (proven above, three ways) |
| Best for | Pure content — posts, docs, notes | Content that mixes prose with components or needs computed values |
Common Misconceptions
Section titled “Common Misconceptions”- “
{frontmatter.title}works the same in.mdand.mdx.” Proven above: it evaluates in.mdxand renders as literal text in.md— the idfrontmattertitleon the (wrong) heading is the tell. - “Astro 7 dropped remark/rehype plugin support entirely.” Proven above:
@astrojs/markdown-remark’sunified()processor still runs them — what changed is that they’re no longer configured as sibling top-levelmarkdown.*keys (those are deprecated), but as arguments tounified(). - “The
layoutfrontmatter key is deprecated.” Per the official docs: it’s not deprecated, it’s scoped — it’s recognized only for individual pages insrc/pages/, and simply not read at all once content lives in a collection. - “A word like
importor a generic type<T>is always safe in prose because it’s just English/code-adjacent text.” Proven above with three distinct real MDX parse errors: MDX’s parser looks for JSX/ESM syntax everywhere outside a code span, regardless of whether a human reader would obviously interpret it as prose.