Phil Vishnevsky

How This Blog Renders MDX

by Phil Vishnevsky · · 22 min

  • mdx
  • astro
  • performance
  • web-dev

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.

MDX pipeline The pipeline from MDX files, through a content collection and the remark and rehype plugins, to static HTML written at build time. MDX pipeline .mdx files src/content/blog Collection Zod-checked frontmatter remark · rehype the plugin stack Static HTML one page per post frontmatter body build
MDX compile flow MDX source parsed to a Markdown AST, transformed by remark plugins, converted to an HTML AST, transformed by rehype plugins, and stringified to HTML. MDX compilation flow MDX the source mdast Markdown AST hast HTML AST HTML what ships remark rehype stringify

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:

MDX
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:

  1. Content Collections define the schema with Zod in src/content/config.ts
  2. getStaticPaths() generates a route for every post at build time
  3. post.render() compiles the MDX through the remark/rehype pipeline
  4. Astro outputs static HTML — no MDX compilation happens in the browser

The schema ensures every post has the right shape:

TypeScript
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:

Astro
---
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:

  1. Remark — operates on the Markdown AST (mdast)
  2. Rehype — operates on the HTML AST (hast)

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:

astro.config.mjs
export default defineConfig({
  integrations: [tailwind({ applyBaseStyles: false }), mdx(), sitemap()],
  markdown: {
    remarkPlugins: [remarkMath, remarkGfm],
    rehypePlugins: [rehypeSlug, rehypeKatex, rehypeWrapTables],
    shikiConfig: {
      themes: { light: pvishLight, dark: pvishDark },
    },
  },
});
PluginStagePurpose
remark-mathRemarkParses $inline$ and $$block$$ math syntax
remark-gfmRemarkEnables GitHub Flavored Markdown (tables, strikethrough, autolinks)
rehype-slugRehypeAuto-generates id attributes on headings for anchor links
rehype-katexRehypeRenders math expressions using KaTeX
rehypeWrapTablesRehypeMy 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:

Caddyfile
jellyfin.example.com {
    encode zstd gzip
    reverse_proxy 192.168.1.10:8096  
    reverse_proxy jellyfin:8096  
}

That block is written as:

Markdown
```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:

TypeScript
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

src/components/mdx/MyComponent.astro
---
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

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:

ComponentTypePurpose
CalloutStaticTips, warnings, and notes, marked by a hairline and a label
DiagramStaticInlines an SVG diagram so it picks up the site’s theme
NerdCornerStaticA collapsible deep dive — a <details>, so it works without JS
ScreensStaticA row of phone screenshots at device proportions
TerminalFrameStaticA window frame around terminal output
CounterInteractiveCounter 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:

CSS
.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:

  1. Content Collections scan src/content/blog/ and validate every post’s frontmatter against the Zod schema
  2. getStaticPaths() generates a route for each post
  3. post.render() compiles each MDX file through the remark/rehype pipeline
  4. Astro outputs static HTML — every page is pre-rendered with zero client-side compilation
  5. 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:

src/layouts/Shell.astro
---
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:

  1. Zero JS by default — blog content ships as pure HTML, with no framework runtime at all
  2. Small scripts for interactivity — the outline, the theme toggle, the explorer, and the odd interactive MDX component
  3. 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:

Astro
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

If you’re building your own MDX pipeline and want to compare notes, I’m happy to share more details.

Saved you an afternoon? You can buy me a tea. ko-fi.com/zenatron ↗

More writing

esc