claude-start-cf

Markdown

Two methods for rendering markdown: static with content-collections (build-time) and dynamic from remote sources (runtime). Both share the same unified processing pipeline.

Markdown processor

src/utils/markdown.ts

The shared pipeline uses unified: remark-parse → remark-gfm → remark-rehype → rehype-slug → rehype-autolink-headings → rehype-stringify. Extracts headings for table-of-contents.

import { renderMarkdown } from '~/utils/markdown'

// Call in any server function or loader
const { markup, headings } = await renderMarkdown(markdownString)

// markup: HTML string ready for rendering
// headings: [{ id, text, level }] for TOC
// src/components/Markdown.tsx
import parse from 'html-react-parser'

// Renders pre-processed HTML with custom element handling:
// - Internal <a> links → TanStack Router <Link>
// - <img> elements → lazy-loaded with rounded styling
<Markdown markup={markup} />

Live preview

renderMarkdown() via server function

Type markdown below and hit render. The processing runs server-side via a POST server function — the client sends raw text and receives HTML.

Click render to see the output.

Static markdown with content-collections

content-collections.ts + src/blog/*.md

Ideal for blog posts bundled in the repo. The Vite plugin processes .md files at build time with Zod-validated frontmatter schemas.

// content-collections.ts
const posts = defineCollection({
  name: 'posts',
  directory: './src/blog',
  include: '*.md',
  schema: (z) => ({
    title: z.string(),
    published: z.string().date(),
    description: z.string().optional(),
    authors: z.string().array(),
  }),
  transform: ({ content, ...post }) => ({
    ...post,
    slug: post._meta.path,
    content: matter(content).body,
  }),
})
// In any route loader
import { allPosts } from 'content-collections'

const sorted = allPosts.sort(
  (a, b) => new Date(b.published).getTime() - new Date(a.published).getTime()
)

Live example: Visit the blog → to see content-collections in action with 2 sample posts.

Dynamic markdown from GitHub

src/utils/docs.functions.ts

Fetch and render markdown from any GitHub repo at runtime via a server function. Great for documentation sites that pull from external repos.

const result = await fetchRemoteMarkdown({
  data: {
    repo: 'tanstack/router',
    branch: 'main',
    filePath: 'README.md',
  },
})
// result.markup — rendered HTML
// result.headings — extracted headings
// result.frontmatter — parsed YAML frontmatter
View on GitHub

Setup checklist

package.json + vite.config.ts

Steps to add markdown rendering to a new project.

  1. Install dependencies
    npm install unified remark-parse remark-gfm remark-rehype rehype-raw rehype-slug rehype-autolink-headings rehype-stringify html-react-parser gray-matter unist-util-visit hast-util-to-string @tailwindcss/typography
  2. Create the processor
    // src/utils/markdown.ts
    export async function renderMarkdown(content: string) {
      // unified pipeline → { markup, headings }
    }
  3. Add content-collections (optional, for static content)
    npm install @content-collections/core @content-collections/vite
  4. Add typography plugin to CSS
    /* src/styles/app.css */
    @plugin "@tailwindcss/typography";
ApproachBest ForTrade-off
content-collectionsBlog posts, static docs bundled with appRequires rebuild for content updates
Dynamic fetchingExternal docs, frequently updated contentRuntime overhead, needs error handling