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.tsThe 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 functionType 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/*.mdIdeal 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.tsFetch 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 frontmatterSetup checklist
package.json + vite.config.tsSteps to add markdown rendering to a new project.
- 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
- Create the processor
// src/utils/markdown.ts export async function renderMarkdown(content: string) { // unified pipeline → { markup, headings } } - Add content-collections (optional, for static content)
npm install @content-collections/core @content-collections/vite
- Add typography plugin to CSS
/* src/styles/app.css */ @plugin "@tailwindcss/typography";
| Approach | Best For | Trade-off |
|---|---|---|
| content-collections | Blog posts, static docs bundled with app | Requires rebuild for content updates |
| Dynamic fetching | External docs, frequently updated content | Runtime overhead, needs error handling |