This guide covers two methods for importing and rendering markdown content in your TanStack Start application:
Both methods share a common rendering pipeline using the unified ecosystem.
Both approaches use the same markdown-to-HTML processing pipeline. First, install the required dependencies:
npm install unified remark-parse remark-gfm remark-rehype rehype-raw rehype-slug rehype-autolink-headings rehype-stringify shiki html-react-parser gray-matter
npm install unified remark-parse remark-gfm remark-rehype rehype-raw rehype-slug rehype-autolink-headings rehype-stringify shiki html-react-parser gray-matter
Create a markdown processor utility:
// src/utils/markdown.ts
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'
export type MarkdownHeading = {
id: string
text: string
level: number
}
export type MarkdownResult = {
markup: string
headings: Array<MarkdownHeading>
}
export async function renderMarkdown(content: string): Promise<MarkdownResult> {
const headings: Array<MarkdownHeading> = []
const result = await unified()
.use(remarkParse) // Parse markdown
.use(remarkGfm) // Support GitHub Flavored Markdown
.use(remarkRehype, { allowDangerousHtml: true }) // Convert to HTML AST
.use(rehypeRaw) // Process raw HTML in markdown
.use(rehypeSlug) // Add IDs to headings
.use(rehypeAutolinkHeadings, {
behavior: 'wrap',
properties: { className: ['anchor'] },
})
.use(() => (tree) => {
// Extract headings for table of contents
const { visit } = require('unist-util-visit')
const { toString } = require('hast-util-to-string')
visit(tree, 'element', (node: any) => {
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName)) {
headings.push({
id: node.properties?.id || '',
text: toString(node),
level: parseInt(node.tagName.charAt(1), 10),
})
}
})
})
.use(rehypeStringify) // Serialize to HTML string
.process(content)
return {
markup: String(result),
headings,
}
}
// src/utils/markdown.ts
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypeStringify from 'rehype-stringify'
export type MarkdownHeading = {
id: string
text: string
level: number
}
export type MarkdownResult = {
markup: string
headings: Array<MarkdownHeading>
}
export async function renderMarkdown(content: string): Promise<MarkdownResult> {
const headings: Array<MarkdownHeading> = []
const result = await unified()
.use(remarkParse) // Parse markdown
.use(remarkGfm) // Support GitHub Flavored Markdown
.use(remarkRehype, { allowDangerousHtml: true }) // Convert to HTML AST
.use(rehypeRaw) // Process raw HTML in markdown
.use(rehypeSlug) // Add IDs to headings
.use(rehypeAutolinkHeadings, {
behavior: 'wrap',
properties: { className: ['anchor'] },
})
.use(() => (tree) => {
// Extract headings for table of contents
const { visit } = require('unist-util-visit')
const { toString } = require('hast-util-to-string')
visit(tree, 'element', (node: any) => {
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(node.tagName)) {
headings.push({
id: node.properties?.id || '',
text: toString(node),
level: parseInt(node.tagName.charAt(1), 10),
})
}
})
})
.use(rehypeStringify) // Serialize to HTML string
.process(content)
return {
markup: String(result),
headings,
}
}
Create a React component that renders the processed HTML with custom element handling:
// src/components/Markdown.tsx
import parse, { type HTMLReactParserOptions, Element } from 'html-react-parser'
import { renderMarkdown, type MarkdownResult } from '~/utils/markdown'
type MarkdownProps = {
content: string
className?: string
}
export function Markdown({ content, className }: MarkdownProps) {
const [result, setResult] = useState<MarkdownResult | null>(null)
useEffect(() => {
renderMarkdown(content).then(setResult)
}, [content])
if (!result) {
return <div className={className}>Loading...</div>
}
const options: HTMLReactParserOptions = {
replace: (domNode) => {
if (domNode instanceof Element) {
// Customize rendering of specific elements
if (domNode.name === 'a') {
// Handle links
const href = domNode.attribs.href
if (href?.startsWith('/')) {
// Internal link - use your router's Link component
return (
<Link to={href}>{domToReact(domNode.children, options)}</Link>
)
}
}
if (domNode.name === 'img') {
// Add lazy loading to images
return (
<img
{...domNode.attribs}
loading="lazy"
className="rounded-lg shadow-md"
/>
)
}
}
},
}
return <div className={className}>{parse(result.markup, options)}</div>
}
// src/components/Markdown.tsx
import parse, { type HTMLReactParserOptions, Element } from 'html-react-parser'
import { renderMarkdown, type MarkdownResult } from '~/utils/markdown'
type MarkdownProps = {
content: string
className?: string
}
export function Markdown({ content, className }: MarkdownProps) {
const [result, setResult] = useState<MarkdownResult | null>(null)
useEffect(() => {
renderMarkdown(content).then(setResult)
}, [content])
if (!result) {
return <div className={className}>Loading...</div>
}
const options: HTMLReactParserOptions = {
replace: (domNode) => {
if (domNode instanceof Element) {
// Customize rendering of specific elements
if (domNode.name === 'a') {
// Handle links
const href = domNode.attribs.href
if (href?.startsWith('/')) {
// Internal link - use your router's Link component
return (
<Link to={href}>{domToReact(domNode.children, options)}</Link>
)
}
}
if (domNode.name === 'img') {
// Add lazy loading to images
return (
<img
{...domNode.attribs}
loading="lazy"
className="rounded-lg shadow-md"
/>
)
}
}
},
}
return <div className={className}>{parse(result.markup, options)}</div>
}
The content-collections package is ideal for static content like blog posts that are included in your repository. It processes markdown files at build time and provides type-safe access to the content.
npm install @content-collections/core @content-collections/vite
npm install @content-collections/core @content-collections/vite
Create a content-collections.ts file in your project root:
// content-collections.ts
import { defineCollection, defineConfig } from '@content-collections/core'
import matter from 'gray-matter'
function extractFrontMatter(content: string) {
const { data, content: body, excerpt } = matter(content, { excerpt: true })
return { data, body, excerpt: excerpt || '' }
}
const posts = defineCollection({
name: 'posts',
directory: './src/blog', // Directory containing your .md files
include: '*.md',
schema: (z) => ({
title: z.string(),
published: z.string().date(),
description: z.string().optional(),
authors: z.string().array(),
}),
transform: ({ content, ...post }) => {
const frontMatter = extractFrontMatter(content)
// Extract header image (first image in the document)
const headerImageMatch = content.match(/!\[([^\]]*)\]\(([^)]+)\)/)
const headerImage = headerImageMatch ? headerImageMatch[2] : undefined
return {
...post,
slug: post._meta.path,
excerpt: frontMatter.excerpt,
description: frontMatter.data.description,
headerImage,
content: frontMatter.body,
}
},
})
export default defineConfig({
collections: [posts],
})
// content-collections.ts
import { defineCollection, defineConfig } from '@content-collections/core'
import matter from 'gray-matter'
function extractFrontMatter(content: string) {
const { data, content: body, excerpt } = matter(content, { excerpt: true })
return { data, body, excerpt: excerpt || '' }
}
const posts = defineCollection({
name: 'posts',
directory: './src/blog', // Directory containing your .md files
include: '*.md',
schema: (z) => ({
title: z.string(),
published: z.string().date(),
description: z.string().optional(),
authors: z.string().array(),
}),
transform: ({ content, ...post }) => {
const frontMatter = extractFrontMatter(content)
// Extract header image (first image in the document)
const headerImageMatch = content.match(/!\[([^\]]*)\]\(([^)]+)\)/)
const headerImage = headerImageMatch ? headerImageMatch[2] : undefined
return {
...post,
slug: post._meta.path,
excerpt: frontMatter.excerpt,
description: frontMatter.data.description,
headerImage,
content: frontMatter.body,
}
},
})
export default defineConfig({
collections: [posts],
})
Add the content-collections plugin to your Vite config:
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
import contentCollections from '@content-collections/vite'
export default defineConfig({
vite: {
plugins: [contentCollections()],
},
})
// app.config.ts
import { defineConfig } from '@tanstack/react-start/config'
import contentCollections from '@content-collections/vite'
export default defineConfig({
vite: {
plugins: [contentCollections()],
},
})
Create markdown files in your designated directory:
## <!-- src/blog/hello-world.md -->
title: Hello World
published: 2024-01-15
authors:
- Jane Doe
description: My first blog post
---

Welcome to my blog! This is my first post.
## Getting Started
Here's some content with **bold** and _italic_ text.
```javascript
console.log('Hello, world!')
```
## <!-- src/blog/hello-world.md -->
title: Hello World
published: 2024-01-15
authors:
- Jane Doe
description: My first blog post
---

Welcome to my blog! This is my first post.
## Getting Started
Here's some content with **bold** and _italic_ text.
```javascript
console.log('Hello, world!')
```
### Using the Collection
Access your posts through the generated collection:
```tsx
// src/routes/blog.index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { allPosts } from 'content-collections'
export const Route = createFileRoute('/blog/')({
component: BlogIndex,
})
function BlogIndex() {
// Posts are sorted by published date
const sortedPosts = allPosts.sort(
(a, b) => new Date(b.published).getTime() - new Date(a.published).getTime()
)
return (
<div>
<h1>Blog</h1>
<ul>
{sortedPosts.map((post) => (
<li key={post.slug}>
<Link to="/blog/$slug" params={{ slug: post.slug }}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<span>{post.published}</span>
</Link>
</li>
))}
</ul>
</div>
)
}
### Using the Collection
Access your posts through the generated collection:
```tsx
// src/routes/blog.index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { allPosts } from 'content-collections'
export const Route = createFileRoute('/blog/')({
component: BlogIndex,
})
function BlogIndex() {
// Posts are sorted by published date
const sortedPosts = allPosts.sort(
(a, b) => new Date(b.published).getTime() - new Date(a.published).getTime()
)
return (
<div>
<h1>Blog</h1>
<ul>
{sortedPosts.map((post) => (
<li key={post.slug}>
<Link to="/blog/$slug" params={{ slug: post.slug }}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<span>{post.published}</span>
</Link>
</li>
))}
</ul>
</div>
)
}
// src/routes/blog.$slug.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { allPosts } from 'content-collections'
import { Markdown } from '~/components/Markdown'
export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => {
const post = allPosts.find((p) => p.slug === params.slug)
if (!post) {
throw notFound()
}
return post
},
component: BlogPost,
})
function BlogPost() {
const post = Route.useLoaderData()
return (
<article>
<header>
<h1>{post.title}</h1>
<p>
By {post.authors.join(', ')} on {post.published}
</p>
</header>
<Markdown content={post.content} className="prose" />
</article>
)
}
// src/routes/blog.$slug.tsx
import { createFileRoute, notFound } from '@tanstack/react-router'
import { allPosts } from 'content-collections'
import { Markdown } from '~/components/Markdown'
export const Route = createFileRoute('/blog/$slug')({
loader: ({ params }) => {
const post = allPosts.find((p) => p.slug === params.slug)
if (!post) {
throw notFound()
}
return post
},
component: BlogPost,
})
function BlogPost() {
const post = Route.useLoaderData()
return (
<article>
<header>
<h1>{post.title}</h1>
<p>
By {post.authors.join(', ')} on {post.published}
</p>
</header>
<Markdown content={post.content} className="prose" />
</article>
)
}
For content stored externally (like GitHub repositories), you can fetch and render markdown dynamically using server functions.
// src/utils/docs.server.ts
import { createServerFn } from '@tanstack/react-start'
import matter from 'gray-matter'
type FetchDocsParams = {
repo: string // e.g., 'tanstack/router'
branch: string // e.g., 'main'
filePath: string // e.g., 'docs/guide/getting-started.md'
}
export const fetchDocs = createServerFn({ method: 'GET' })
.validator((params: FetchDocsParams) => params)
.handler(async ({ data: { repo, branch, filePath } }) => {
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`
const response = await fetch(url, {
headers: {
// Add GitHub token for private repos or higher rate limits
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const rawContent = await response.text()
const { data: frontmatter, content } = matter(rawContent)
return {
frontmatter,
content,
filePath,
}
})
// src/utils/docs.server.ts
import { createServerFn } from '@tanstack/react-start'
import matter from 'gray-matter'
type FetchDocsParams = {
repo: string // e.g., 'tanstack/router'
branch: string // e.g., 'main'
filePath: string // e.g., 'docs/guide/getting-started.md'
}
export const fetchDocs = createServerFn({ method: 'GET' })
.validator((params: FetchDocsParams) => params)
.handler(async ({ data: { repo, branch, filePath } }) => {
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`
const response = await fetch(url, {
headers: {
// Add GitHub token for private repos or higher rate limits
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const rawContent = await response.text()
const { data: frontmatter, content } = matter(rawContent)
return {
frontmatter,
content,
filePath,
}
})
For production, add appropriate cache headers:
export const fetchDocs = createServerFn({ method: 'GET' })
.validator((params: FetchDocsParams) => params)
.handler(async ({ data: { repo, branch, filePath }, context }) => {
// Set cache headers for CDN caching
context.response.headers.set(
'Cache-Control',
'public, max-age=0, must-revalidate',
)
context.response.headers.set(
'CDN-Cache-Control',
'max-age=300, stale-while-revalidate=300',
)
// ... fetch logic
})
export const fetchDocs = createServerFn({ method: 'GET' })
.validator((params: FetchDocsParams) => params)
.handler(async ({ data: { repo, branch, filePath }, context }) => {
// Set cache headers for CDN caching
context.response.headers.set(
'Cache-Control',
'public, max-age=0, must-revalidate',
)
context.response.headers.set(
'CDN-Cache-Control',
'max-age=300, stale-while-revalidate=300',
)
// ... fetch logic
})
// src/routes/docs.$path.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fetchDocs } from '~/utils/docs.server'
import { Markdown } from '~/components/Markdown'
export const Route = createFileRoute('/docs/$path')({
loader: async ({ params }) => {
return fetchDocs({
data: {
repo: 'your-org/your-repo',
branch: 'main',
filePath: `docs/${params.path}.md`,
},
})
},
component: DocsPage,
})
function DocsPage() {
const { frontmatter, content } = Route.useLoaderData()
return (
<article>
<h1>{frontmatter.title}</h1>
<Markdown content={content} className="prose" />
</article>
)
}
// src/routes/docs.$path.tsx
import { createFileRoute } from '@tanstack/react-router'
import { fetchDocs } from '~/utils/docs.server'
import { Markdown } from '~/components/Markdown'
export const Route = createFileRoute('/docs/$path')({
loader: async ({ params }) => {
return fetchDocs({
data: {
repo: 'your-org/your-repo',
branch: 'main',
filePath: `docs/${params.path}.md`,
},
})
},
component: DocsPage,
})
function DocsPage() {
const { frontmatter, content } = Route.useLoaderData()
return (
<article>
<h1>{frontmatter.title}</h1>
<Markdown content={content} className="prose" />
</article>
)
}
To build navigation from a GitHub directory:
// src/utils/docs.server.ts
type GitHubContent = {
name: string
path: string
type: 'file' | 'dir'
}
export const fetchRepoContents = createServerFn({ method: 'GET' })
.validator((params: { repo: string; branch: string; path: string }) => params)
.handler(async ({ data: { repo, branch, path } }) => {
const url = `https://api.github.com/repos/${repo}/contents/${path}?ref=${branch}`
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch contents: ${response.status}`)
}
const contents: Array<GitHubContent> = await response.json()
return contents
.filter((item) => item.type === 'file' && item.name.endsWith('.md'))
.map((item) => ({
name: item.name.replace('.md', ''),
path: item.path,
}))
})
// src/utils/docs.server.ts
type GitHubContent = {
name: string
path: string
type: 'file' | 'dir'
}
export const fetchRepoContents = createServerFn({ method: 'GET' })
.validator((params: { repo: string; branch: string; path: string }) => params)
.handler(async ({ data: { repo, branch, path } }) => {
const url = `https://api.github.com/repos/${repo}/contents/${path}?ref=${branch}`
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3+json',
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch contents: ${response.status}`)
}
const contents: Array<GitHubContent> = await response.json()
return contents
.filter((item) => item.type === 'file' && item.name.endsWith('.md'))
.map((item) => ({
name: item.name.replace('.md', ''),
path: item.path,
}))
})
For code blocks with syntax highlighting, integrate Shiki into your markdown processor:
// src/utils/markdown.ts
import { codeToHtml } from 'shiki'
// Process code blocks after parsing
export async function highlightCode(
code: string,
language: string,
): Promise<string> {
return codeToHtml(code, {
lang: language,
themes: {
light: 'github-light',
dark: 'tokyo-night',
},
})
}
// src/utils/markdown.ts
import { codeToHtml } from 'shiki'
// Process code blocks after parsing
export async function highlightCode(
code: string,
language: string,
): Promise<string> {
return codeToHtml(code, {
lang: language,
themes: {
light: 'github-light',
dark: 'tokyo-night',
},
})
}
Then handle code blocks in your Markdown component:
// In your Markdown component's replace function
if (domNode.name === 'pre') {
const codeElement = domNode.children.find(
(child) => child instanceof Element && child.name === 'code',
)
if (codeElement) {
const className = codeElement.attribs.class || ''
const language = className.replace('language-', '') || 'text'
const code = getText(codeElement)
return <CodeBlock code={code} language={language} />
}
}
// In your Markdown component's replace function
if (domNode.name === 'pre') {
const codeElement = domNode.children.find(
(child) => child instanceof Element && child.name === 'code',
)
if (codeElement) {
const className = codeElement.attribs.class || ''
const language = className.replace('language-', '') || 'text'
const code = getText(codeElement)
return <CodeBlock code={code} language={language} />
}
}
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| content-collections | Blog posts, static docs bundled with app | Type-safe, build-time processing, fast runtime | Requires rebuild for content updates |
| Dynamic fetching | External docs, frequently updated content | Always fresh, no rebuild needed | Runtime overhead, requires error handling |
Choose the approach that best fits your content update frequency and deployment workflow. For hybrid scenarios, you can use both methods in the same application.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.