MDX patterns

How this site mixes MDX with Vue islands and dynamic data.

MDX patterns

How this repo uses MDX with Vue islands, Cloudflare Pages Functions, and Obsidian-style links.

Mental model

  • .mdx files 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:

  1. MDX page renders a Vue island.
  2. The island hydrates in the browser.
  3. The island fetches JSON from /api/....
  4. 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:

  • title becomes the H1 and the browser tab title via the layout.
  • pubDate controls sort order in the index views.
  • tags are rendered as chips and power /tags/{tag} pages.
  • summary is 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-guide through the docs collection.
  • Wikilinking between docs keeps Obsidian and the site in sync.
  • Inside the /posts collection, 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:

  1. Frontmatter at the top.
  2. Imports next.
  3. Markdown content and islands after that.

…then MDX stays predictable and this site keeps a clean separation between content, components, and infrastructure.