TanStack
Styling & Metadata

Rendering Markdown

Use TanStack Markdown to render Markdown in your TanStack Start application. Its React renderer works synchronously during server rendering and client navigation, so content does not need to wait for a client effect.

This guide covers static posts loaded with Content Collections and remote content fetched through a server function. Both use the same Markdown component.

Rendering Markdown

Install TanStack Markdown:

shell
npm install @tanstack/markdown

Create a shared component that accepts either Markdown source or a parsed document:

tsx
// src/components/Markdown.tsx
import { Markdown as ReactMarkdown } from '@tanstack/markdown/react'
import type { MarkdownDocument } from '@tanstack/markdown'

type MarkdownProps = {
  content: string | MarkdownDocument
  className?: string
}

export function Markdown({ content, className }: MarkdownProps) {
  return (
    <div className={className}>
      <ReactMarkdown headingAnchors>{content}</ReactMarkdown>
    </div>
  )
}

Headings receive stable IDs, and headingAnchors adds links to them. Raw HTML is escaped and unsafe URL protocols are removed by default. Keep these defaults for remote or user-written content. TanStack Markdown does not execute MDX or implement every CommonMark or GFM feature, so check the supported syntax when migrating existing content.

Use the components prop to customize elements, for example to lazy-load images:

tsx
<ReactMarkdown
  components={{
    img: (props) => <img {...props} loading="lazy" />,
  }}
>
  {content}
</ReactMarkdown>

The examples below use ~/* for the src/* import alias. The prose class is optional and requires your own typography styles, such as Tailwind Typography.

Static Markdown with Content Collections

Content Collections loads local Markdown files and validates their frontmatter. Pass the Markdown body from the generated collection to the shared renderer.

Installation and Configuration

shell
npm install -D @content-collections/core @content-collections/vite zod
ts
// content-collections.ts
import { defineCollection, defineConfig } from '@content-collections/core'
import { z } from 'zod'

const posts = defineCollection({
  name: 'posts',
  directory: './src/blog',
  include: '*.md',
  schema: z.object({
    title: z.string(),
    published: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    description: z.string(),
    authors: z.array(z.string()),
    content: z.string(),
  }),
  transform: (post) => ({
    ...post,
    slug: post._meta.path,
  }),
})

export default defineConfig({
  collections: [posts],
})

Content Collections already separates frontmatter from content, so this transform does not need a second frontmatter parser.

Add the plugin to your existing Vite configuration, keeping any deployment plugins your app needs:

ts
// vite.config.ts
import contentCollections from '@content-collections/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [
    contentCollections(),
    tsConfigPaths({ projects: ['./tsconfig.json'] }),
    tanstackStart(),
    viteReact(),
  ],
})

Add the generated collection alias to your existing tsconfig.json:

json
{
  "compilerOptions": {
    "paths": {
      "~/*": ["./src/*"],
      "content-collections": ["./.content-collections/generated"]
    }
  }
}

Add .content-collections to .gitignore. Starting the Vite dev server or running a build generates the collection.

Creating a Post

Create src/blog/hello-world.md:

markdown
---
title: Hello World
published: '2026-01-15'
description: My first blog post
authors:
  - Jane Doe
---

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!')
```

Listing Posts

tsx
// src/routes/blog.index.tsx
import { createFileRoute, Link } from '@tanstack/react-router'
import { allPosts } from 'content-collections'

export const Route = createFileRoute('/blog/')({
  component: BlogIndex,
})

function BlogIndex() {
  const sortedPosts = [...allPosts].sort((a, b) =>
    b.published.localeCompare(a.published),
  )

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {sortedPosts.map((post) => (
          <li key={post.slug}>
            <Link to="/blog/$slug" params={{ slug: post.slug }}>
              {post.title}
            </Link>
            <p>{post.description}</p>
            <time dateTime={post.published}>{post.published}</time>
          </li>
        ))}
      </ul>
    </main>
  )
}

Rendering a Post

tsx
// 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((post) => post.slug === params.slug)
    if (!post) {
      throw notFound()
    }
    return post
  },
  component: BlogPost,
})

function BlogPost() {
  const post = Route.useLoaderData()

  return (
    <article>
      <h1>{post.title}</h1>
      <p>
        By {post.authors.join(', ')} on {post.published}
      </p>
      <Markdown content={post.content} className="prose" />
    </article>
  )
}

The renderer parses the Markdown synchronously and renders React elements on both the server and client.

Remote Markdown with a Server Function

Fetch remote content in a server function and parse it before returning it to the route. Install gray-matter if the remote files contain frontmatter:

shell
npm install gray-matter

Keep the repository, branch, and content directory in server code. Validate the requested filename before building the URL:

ts
// src/utils/docs.ts
import { createServerFn } from '@tanstack/react-start'
import { notFound } from '@tanstack/react-router'
import { parseMarkdown } from '@tanstack/markdown/parser'
import matter from 'gray-matter'

export const fetchDocs = createServerFn({ method: 'GET' })
  .validator((slug: unknown) => {
    if (typeof slug !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
      throw new Error('Invalid document slug')
    }
    return slug
  })
  .handler(async ({ data: slug }) => {
    // Replace this with your public repository's content directory.
    const baseUrl =
      'https://raw.githubusercontent.com/your-org/your-repo/main/docs'
    const response = await fetch(`${baseUrl}/${slug}.md`)

    if (response.status === 404) {
      throw notFound()
    }
    if (!response.ok) {
      throw new Error(`Failed to fetch document: ${response.status}`)
    }

    const { data, content } = matter(await response.text())

    return {
      title: typeof data.title === 'string' ? data.title : slug,
      document: parseMarkdown(content),
    }
  })
tsx
// src/routes/docs.$slug.tsx
import { createFileRoute } from '@tanstack/react-router'
import { Markdown } from '~/components/Markdown'
import { fetchDocs } from '~/utils/docs'

export const Route = createFileRoute('/docs/$slug')({
  loader: ({ params }) => fetchDocs({ data: params.slug }),
  component: DocsPage,
})

function DocsPage() {
  const { title, document } = Route.useLoaderData()

  return (
    <article>
      <h1>{title}</h1>
      <Markdown content={document} className="prose" />
    </article>
  )
}

Relative Markdown links and image URLs resolve against your app's URL, not the source repository. Use app-relative or absolute URLs in the content, or transform them for your site's route and asset layout.

Syntax Highlighting with TanStack Highlight

Install TanStack Highlight. The Markdown adapter requires version 0.0.6 or newer:

shell
npm install @tanstack/highlight

Register the languages your content uses and create the Markdown adapter:

ts
// src/utils/markdown-highlighter.ts
import { createHighlighter } from '@tanstack/highlight/core'
import { js } from '@tanstack/highlight/languages/js'
import { plaintext } from '@tanstack/highlight/languages/plaintext'
import { ts } from '@tanstack/highlight/languages/ts'
import { tsx } from '@tanstack/highlight/languages/tsx'
import { createTanStackMarkdownHighlighter } from '@tanstack/highlight/markdown'

const highlighter = createHighlighter({
  languages: [plaintext, js, ts, tsx],
})

export const highlightMarkdownCode =
  createTanStackMarkdownHighlighter(highlighter)

Update the shared component to pass the highlighter and include its theme styles:

tsx
// src/components/Markdown.tsx
import { createThemeCss } from '@tanstack/highlight/theme'
import { githubDarkTheme } from '@tanstack/highlight/themes/github-dark'
import { githubLightTheme } from '@tanstack/highlight/themes/github-light'
import { Markdown as ReactMarkdown } from '@tanstack/markdown/react'
import type { MarkdownDocument } from '@tanstack/markdown'
import { highlightMarkdownCode } from '~/utils/markdown-highlighter'

const themeCss = createThemeCss({
  light: githubLightTheme,
  dark: githubDarkTheme,
  lightSelector: '.markdown-renderer',
  darkSelector: '.dark .markdown-renderer',
  codeBlockSelector: '.markdown-renderer pre.tm-code',
  lineNumbersSelector: '.markdown-renderer .tm-code--line-numbers',
})

type MarkdownProps = {
  content: string | MarkdownDocument
  className?: string
}

export function Markdown({ content, className }: MarkdownProps) {
  return (
    <div className={`markdown-renderer ${className ?? ''}`}>
      <style>{themeCss}</style>
      <ReactMarkdown headingAnchors highlighter={highlightMarkdownCode}>
        {content}
      </ReactMarkdown>
    </div>
  )
}

The adapter returns escaped token markup inside the renderer's own <pre><code> elements. Unknown languages fall back to plain text. Add a dark class to an ancestor to select the dark theme. See the syntax highlighting guide for line numbers and highlighted lines.

Table of Contents

To collect headings, add the docs preset when parsing the document:

ts
import { docsMarkdownExtensions } from '@tanstack/markdown/extensions/docs'
import { parseMarkdown } from '@tanstack/markdown/parser'

const document = parseMarkdown(content, {
  extensions: docsMarkdownExtensions(),
})

const headings = document.headings ?? []
// Each heading has an id, text, and level.

Use this in the server function, then render links to each heading's id. The docs preset guide also covers callouts and custom comment components. Those features need the same extensions passed to the renderer.