How This Blog Renders MDX
I wanted a blog that felt like Markdown, but could still render components, callouts, and embeds without turning into a templating mess. MDX hit the sweet spot — and Astro makes it fly.
Why MDX (not just Markdown)
Markdown is simple and portable. The downside: you’re stuck with plain content. MDX lets you import components right inside a post — here, plain Astro components, which means you can drop in things like callouts, counters, or custom embeds. Under the hood, MDX turns your content into a structured syntax tree (think AST) before it becomes HTML.
If you’ve used a static site generator before, MDX feels like “Markdown with superpowers” — but it’s still readable in plain text, which keeps the writing workflow fast.
Example:
import Callout from '@/components/mdx/Callout.astro';
<Callout type="info" title="Quick tip">
This is an MDX component rendered inside your post.
</Callout>The pipeline: content collections to compiled HTML
At a high level, my posts live in src/content/blog/*.mdx. Astro’s Content Collections handle the heavy lifting: schema validation, frontmatter parsing, and compilation all happen at build time.
Here’s how it works:
- Content Collections define the schema with Zod in
src/content/config.ts getStaticPaths()generates a route for every post at build timepost.render()compiles the MDX through the remark/rehype pipeline- Astro outputs static HTML — no MDX compilation happens in the browser
The schema ensures every post has the right shape:
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
date: z.string(),
readingTime: z.string().optional(),
excerpt: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});And the page route at src/pages/blog/[slug].astro renders each post:
---
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const rendered = await post.render();
const Content = rendered.Content;
const headings = rendered.headings ?? [];
---
<article>
<div class="prose">
<Content />
</div>
</article>The key point: the MDX compilation happens at build time, not in the browser. Visitors get clean, static HTML with zero JavaScript needed to display the content.
The remark/rehype plugin pipeline
MDX doesn’t just parse Markdown — it runs your content through a unified pipeline with two processing stages:
The flow looks like this:
MDX Source
|
Parse to mdast (Markdown AST)
|
Remark plugins transform mdast
|
Convert to hast (HTML AST)
|
Rehype plugins transform hast
|
Stringify to HTML + component islands
My plugin stack
The plugins are configured once in astro.config.mjs and apply to all Markdown and MDX content:
export default defineConfig({
integrations: [tailwind({ applyBaseStyles: false }), mdx(), sitemap()],
markdown: {
remarkPlugins: [remarkMath, remarkGfm],
rehypePlugins: [rehypeSlug, rehypeKatex, rehypeWrapTables],
shikiConfig: {
themes: { light: pvishLight, dark: pvishDark },
},
},
});| Plugin | Stage | Purpose |
|---|---|---|
| remark-math | Remark | Parses $inline$ and $$block$$ math syntax |
| remark-gfm | Remark | Enables GitHub Flavored Markdown (tables, strikethrough, autolinks) |
| rehype-slug | Rehype | Auto-generates id attributes on headings for anchor links |
| rehype-katex | Rehype | Renders math expressions using KaTeX |
rehypeWrapTables | Rehype | My own, about fifteen lines: wraps each table in a scroll container so a wide one scrolls on a phone instead of being clipped |
Astro handles syntax highlighting out of the box with Shiki, so there’s no need for a separate rehype-highlight plugin. The one thing I changed is the theme: a stock theme like github-dark drops a dozen colors the rest of the site never uses into its most prominent element, so src/lib/shiki-theme.js maps Shiki’s scopes onto the site’s own palette, once for light and once for dark.
Shiki also takes transformers, which rewrite a block’s HTML as it’s highlighted. Three from @shikijs/transformers and one of my own let a block say more than its code: which file it belongs to, which lines matter, and what changed. It’s all written in the fence and in comments, and the comments are stripped on the way out, so the copy button still copies the file as it should be:
jellyfin.example.com {
encode zstd gzip
reverse_proxy 192.168.1.10:8096
reverse_proxy jellyfin:8096
}That block is written as:
```caddyfile title="Caddyfile" {2}
jellyfin.example.com {
encode zstd gzip
reverse_proxy 192.168.1.10:8096 # [!code --]
reverse_proxy jellyfin:8096 # [!code ++]
}
```Table of contents + heading extraction
I wanted a TOC on every post. Astro makes this easy — when you call post.render(), it returns a headings array alongside the compiled Content component:
const rendered = await post.render();
const headings = rendered.headings ?? [];
// Each heading: { slug: string, text: string, depth: number }These headings are extracted by rehype-slug during compilation, so the IDs are stable across builds. The outline component receives them, indents each h3 under its h2, and a small script highlights the section you’re in. It tracks scroll position rather than using IntersectionObserver, so the current heading is the last one you’ve passed — an observer fires from the middle of the viewport and jumps around.
Creating custom MDX components
Want to add a new component? Here’s the pattern I use:
Step 1: Create the component
---
interface Props { title: string }
const { title } = Astro.props;
---
<div class="box">
<p class="title">{title}</p>
<slot />
</div>
<style>
.box {
padding: var(--s-4);
border: 1px solid var(--c-hairline);
border-radius: var(--radius-card);
}
.title { font-weight: 600; }
</style>Step 2: Import and use it in your MDX
import MyComponent from '@/components/mdx/MyComponent.astro';
<MyComponent title="Hello">
This content renders inside the component.
</MyComponent>With Astro’s MDX integration, imports work directly in .mdx files — no component registry needed. If a component needs to do something in the browser, it gets a <script> of its own, which Vite bundles and ships only on pages that use it. The counter below is about ten lines of that.
Current component inventory
Here’s what I’ve got available:
| Component | Type | Purpose |
|---|---|---|
Callout | Static | Tips, warnings, and notes, marked by a hairline and a label |
Diagram | Static | Inlines an SVG diagram so it picks up the site’s theme |
NerdCorner | Static | A collapsible deep dive — a <details>, so it works without JS |
Screens | Static | A row of phone screenshots at device proportions |
TerminalFrame | Static | A window frame around terminal output |
Counter | Interactive | Counter demo, with a few lines of inline script |
The constraint is intentional: fewer components means fewer ways for content to break over time.
Styling: a focused MDX layer
All post content is wrapped in .prose, with typography and media styles in src/styles/prose.css. That means:
- Code blocks look good without extra wrappers
- Tables are readable
- Images and iframes are responsive by default
Because the styles are centralized in a single file, I can tweak typography without editing every post. That’s a small thing that pays off over time.
Every value in it is a custom property from src/styles/tokens.css, where the whole palette lives. Each token is defined twice, once for light and once for dark, so the posts switch themes without a single theme-specific rule:
.prose {
max-width: 68ch;
font-size: var(--t-sans-md);
line-height: var(--lh-prose);
color: var(--c-text);
}
.prose :not(pre) > code {
font-family: var(--font-mono);
color: var(--c-link);
background: var(--c-tint-link);
border-radius: var(--radius-sm);
}
.prose pre {
background: var(--c-sunken);
border: 1px solid var(--c-hairline);
border-radius: var(--radius-card);
overflow-x: auto;
}The build process: what actually happens
When you run astro build, here’s the flow:
- Content Collections scan
src/content/blog/and validate every post’s frontmatter against the Zod schema getStaticPaths()generates a route for each postpost.render()compiles each MDX file through the remark/rehype pipeline- Astro outputs static HTML — every page is pre-rendered with zero client-side compilation
- Vite bundles the handful of small scripts the pages use
This is Static Site Generation (SSG) — pages are built once and served as static HTML. Astro ships zero JavaScript by default; everything that runs in the browser here is a script I wrote on purpose.
SEO considerations
MDX plays nicely with SEO because the output is clean HTML. Here’s what I’m doing:
Semantic HTML structure
- One
<h1>per page (the post title, rendered from frontmatter) - Proper heading hierarchy (
h2->h3->h4) <time>elements for dates<article>wrapper for the main content
Meta tags and Open Graph
The site’s one layout, Shell.astro, handles meta tags, and I generate OG images with Satori at /src/pages/og/[...slug].png.ts:
---
const { title, ogSlug } = Astro.props;
---
<head>
<title>{title}</title>
<meta property="og:title" content={title} />
<meta property="og:image" content={`https://pvi.sh/og/${ogSlug}.png`} />
</head>Each blog post gets a unique OG image generated at build time — no external service needed.
URL structure
Clean URLs like /blog/my-post-slug are handled automatically by Astro’s file-based routing. A file at src/content/blog/my-post.mdx becomes /blog/my-post via the dynamic route in src/pages/blog/[slug].astro.
Performance: where it matters (and where it doesn’t)
Astro’s architecture means most performance concerns are already handled:
- Zero JS by default — blog content ships as pure HTML, with no framework runtime at all
- Small scripts for interactivity — the outline, the theme toggle, the explorer, and the odd interactive MDX component
- Build-time compilation — MDX is compiled once, not on every request
Performance optimizations I’ve made
Math styles only where there’s math — KaTeX’s stylesheet is the heaviest CSS the site has, so the post page checks the source for $…$ and $$…$$ and only links it when a post actually uses them:
const hasMath = /\$\$|\\\(|\\\[|\$[^$\n]+\$/.test(post.body ?? "");
---
<Shell math={hasMath}>Diagrams are inlined — an SVG loaded through <img> is its own document and can’t see the page’s CSS, so it would be stuck in whatever colors it was drawn in. The Diagram component reads the file at build time and inlines it, which makes every diagram follow the theme and saves a request each.
Two self-hosted fonts, preloaded — one variable file for the sans and one for the mono, subset to Latin, so text renders in the right face on the first paint.
Rendering model (why the page still feels snappy)
Astro renders everything to static HTML first. Unlike a traditional SPA, where the whole page is a JavaScript application, the page here is finished before any script runs; the scripts only add behavior to HTML that already works.
Moving between pages uses Astro’s view transitions: the titlebar, tabs, explorer and status bar stay where they are, and only the content pane swaps, the way an app window changes documents. With JavaScript off, the same links are ordinary page loads.
The parts I don’t want to compromise on
- Readable source files (writing should feel like writing, not coding).
- Composable components (callouts, embeds, and custom UI).
- Plain SEO-friendly HTML on the other side.
- Near-zero JavaScript for content that doesn’t need it.
MDX on Astro checks all four boxes.
Measuring performance (lightweight but honest)
I keep an eye on:
- Build time (does compiling all posts start to drag?)
- Page load on mobile (do embeds slow things down?)
- Core Web Vitals for individual posts
- JavaScript bundle size per page (islands should stay small)
If any of those start to slip, I’ll adjust the pipeline rather than throwing more JS at the browser.
What I’d improve next
- Prebuilt search index for faster filtering in the explorer — consider Fuse.js or Pagefind
- Image optimization with Astro’s
<Image>component for automatic format conversion and responsive sizing in posts (the photos page already does this, with its own script)
View transitions and an RSS feed were on this list once; both have shipped.
A note on images and embeds
Images live in public/images/blog/..., which keeps paths stable and makes them easy to reference from MDX. For videos, I use responsive <iframe> embeds that inherit the .prose styling so they don’t overflow the layout on small screens.
Further reading
- MDX Official Documentation — the definitive guide
- Astro Content Collections — how Astro handles structured content
- Unified.js ecosystem — the parser that powers remark/rehype
- Astro Islands — the architecture behind partial hydration
- Core Web Vitals — what to measure for performance
Examples you can read next
If you’re building your own MDX pipeline and want to compare notes, I’m happy to share more details.