MDX patterns
How this repo uses MDX with Vue islands, Cloudflare Pages Functions, and Obsidian-style links.
Mental model
.mdxfiles are written as Markdown plus the option to import components.- Frontmatter (
title,pubDate,tags,summary) feeds the posts/docs index and the shared layout. - Docs and posts render through
src/layouts/Post.astro, so MDX files only worry about content. - Vue components become islands when you add a
client:*directive.
Example: plain MDX page (no components)
---
title: "Plain MDX doc"
pubDate: 2024-06-10
tags: ["docs"]
summary: "MDX file that only uses Markdown."
---
# Plain doc
This file could have been `.md`; using `.mdx` just keeps the option open to import components later.
Vue islands
Import Vue components with a relative path and attach a hydration directive:
import GithubStars from '../../components/GithubStars.vue'
<GithubStars client:load repo="withastro/astro" />
Common directives:
client:load– hydrate as soon as the page has loaded.client:visible– hydrate when the component scrolls into view.client:idle– hydrate when the browser is idle (good for non-critical UI).client:only="vue"– skip server render and do everything on the client.
Props passed to a Vue island must be JSON-serializable because Astro sends them down in the HTML for hydration.
Example: small MDX page with a Vue island
A minimal post that embeds a Vue counter island.
---
title: "Demo: Vue island in MDX"
pubDate: 2024-06-15
tags: ["astro", "mdx", "demo"]
summary: "Tiny MDX page that uses a Vue component."
---
import Counter from '../../components/Counter.vue'
# Vue island demo
This paragraph is static Markdown.
Below is a Vue component that hydrates only when visible:
<Counter client:visible initial="5" />
Counter.vue might look like this:
<template>
<button type="button" @click="count++">
Count: {{ count }}
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{ initial?: number }>()
const count = ref(props.initial ?? 0)
</script>
Dynamic data via Functions
Vue islands can call Cloudflare Pages Functions for live data. A typical flow:
- MDX page renders a Vue island.
- The island hydrates in the browser.
- The island fetches JSON from
/api/.... - A Pages Function responds, optionally caching the result.
Example: GitHub stars island + Pages Function
MDX usage:
---
title: "Demo: GitHub stars from a Function"
pubDate: 2024-06-20
tags: ["astro", "mdx", "islands"]
summary: "MDX + Vue island pulling data from Cloudflare Pages Functions."
---
import GithubStars from '../../components/GithubStars.vue'
# GitHub stars demo
GitHub stars for Astro, pulled at request time:
<GithubStars client:visible repo="withastro/astro" />
GithubStars.vue (simplified):
<template>
<span v-if="error">?</span>
<span v-else-if="loading">…</span>
<span v-else>{{ stars.toLocaleString() }}</span>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{ repo: string }>()
const stars = ref<number | null>(null)
const loading = ref(true)
const error = ref(false)
onMounted(async () => {
try {
const res = await fetch(`/api/github-stars?repo=${encodeURIComponent(props.repo)}`)
if (!res.ok) throw new Error('Request failed')
const json = await res.json() as { stars: number }
stars.value = json.stars
} catch (e) {
error.value = true
} finally {
loading.value = false
}
})
</script>
Cloudflare Pages Function at /api/github-stars:
Create functions/api/github-stars.js (or .ts) in the project root:
export async function onRequest(context) {
const { request } = context
const url = new URL(request.url)
const repo = url.searchParams.get('repo')
if (!repo) {
return new Response(JSON.stringify({ error: 'Missing repo param' }), {
status: 400,
headers: { 'content-type': 'application/json' },
})
}
const apiUrl = `https://api.github.com/repos/${repo}`
// Basic caching via the default Cloudflare cache
const cache = caches.default
const cacheKey = new Request(request.url, request)
let response = await cache.match(cacheKey)
if (!response) {
const githubRes = await fetch(apiUrl, {
headers: { 'User-Agent': 'droolycode-mdx-guide' },
})
if (!githubRes.ok) {
return new Response(JSON.stringify({ error: 'GitHub error' }), {
status: 502,
headers: { 'content-type': 'application/json' },
})
}
const data = await githubRes.json()
response = new Response(
JSON.stringify({ stars: data.stargazers_count ?? 0 }),
{
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=300',
},
},
)
// Fire-and-forget cache write
context.waitUntil(cache.put(cacheKey, response.clone()))
}
return response
}
This keeps the MDX file simple while still serving fresh-ish data.
Frontmatter conventions
Every MDX file uses the shared content schema in src/content/config.ts. Typical frontmatter:
title: “Post title” pubDate: 2024-06-01 tags: [“astro”, “demo”] summary: “Optional excerpt visible in lists.”
Notes:
titlebecomes the H1 and the browser tab title via the layout.pubDatecontrols sort order in the index views.tagsare rendered as chips and power/tags/{tag}pages.summaryis optional; when present it shows up on index cards.
Linking patterns
- Regular Markdown links for external URLs:
[Astro docs](https://docs.astro.build/). - Obsidian-style wikilinks like
[[mdx-guide]]resolve to/docs/mdx-guidethrough the docs collection. - Wikilinking between docs keeps Obsidian and the site in sync.
- Inside the
/postscollection, keep links relative (no../..) so builds stay happy.
Layout and MDX
Both posts and docs are rendered through src/layouts/Post.astro:
- The layout reads frontmatter from the content collection entry.
- MDX files do not import a layout or wrapper; they export only content.
- Things like metadata, tag chips, and comments live entirely in the layout.
If you stick to:
- Frontmatter at the top.
- Imports next.
- Markdown content and islands after that.
…then MDX stays predictable and this site keeps a clean separation between content, components, and infrastructure.