Setting up Markdown rendering
Renders Markdown content (e.g. guides, articles) into styled HTML using @tanstack/markdown,
shadcn's typeset.css for prose styling, and optionally @tanstack/highlight for code block syntax
highlighting.
Prerequisites
- A TanStack Start project with Tailwind CSS and shadcn/ui already configured
Steps
Install dependencies
bun add @tanstack/markdown- shadcn's typeset isn't installed as a package, download the stylesheet directly and store it in the project
curl -o src/typeset.css https://ui.shadcn.com/typeset.css- Import it from
styles.css, after the other base imports
/* src/styles.css */ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; @import "./typeset.css";Configure markdown formatting
src/components/article.tsx— renders a parsed Markdown document withtypesetstyling
// src/components/article.tsx import type { MarkdownInput } from "@tanstack/markdown"; import { Markdown } from "@tanstack/markdown/react"; export default function Article({ children }: { children: MarkdownInput }) { return ( <article className="typeset"> <Markdown>{children}</Markdown> </article> ); }(optional) Add highlighting with TanStack Highlight
- Adds syntax highlighting to fenced code blocks inside rendered Markdown
bun add @tanstack/highlightsrc/lib/markdown.ts— isomorphic highlighter setup, one entry per language needed
// src/lib/markdown.ts import { createHighlighter } from "@tanstack/highlight/core"; import { shell } from "@tanstack/highlight/languages/shell"; import { tsx } from "@tanstack/highlight/languages/tsx"; import { createTanStackMarkdownHighlighter } from "@tanstack/highlight/markdown"; import { createThemeCss } from "@tanstack/highlight/theme"; import { draculaTheme } from "@tanstack/highlight/themes/dracula"; import type { CodeHighlighter } from "@tanstack/markdown"; const highlighter = createHighlighter({ // Add more languages here as needed, e.g. `ts`, `json`, `yaml` languages: [tsx, shell], }); export const highlightMarkdownCode: CodeHighlighter = createTanStackMarkdownHighlighter(highlighter); export const highlightCss = createThemeCss({ dark: draculaTheme, // Scopes the dark theme to `:root`, which always matches — the site is dark-mode only darkSelector: ":root", });- Pass the highlighter into
Article
+ import { highlightMarkdownCode } from "@/lib/markdown"; export default function Article({ children }: { children: MarkdownInput }) { return ( <article className="typeset"> - <Markdown>{children}</Markdown> + <Markdown highlighter={highlightMarkdownCode}>{children}</Markdown> </article> ); }- Inject
highlightCssinto the document head via the root route
// src/routes/__root.tsx import { highlightCss } from "@/lib/markdown"; export const Route = createRootRoute({ head: () => ({ // ...meta, links styles: [{ children: highlightCss }], }), component: RootDocument, });Render markdown content into a route
- Store post source files as
.mdin aposts/directory at the project root - Expose server functions that read and parse them
// src/lib/blog.functions.ts import { readdir, readFile } from "fs/promises"; import { createServerFn } from "@tanstack/react-start"; import { parseMarkdown } from "@tanstack/markdown/parser"; import { z } from "zod"; export const listPostsFn = createServerFn().handler(async () => { const files = await readdir("../posts"); return files.map((file) => file.replace(/\.md$/, "")); }); export const getPostFn = createServerFn() .validator(z.string()) .handler(async ({ data: slug }) => { const text = await readFile(`../posts/${slug}.md`, "utf-8"); return parseMarkdown(text); });src/routes/posts/index.tsx— lists every post slug, linking to its route
// src/routes/posts/index.tsx import { createFileRoute, Link } from "@tanstack/react-router"; import { listPostsFn } from "@/lib/blog.functions"; export const Route = createFileRoute("/posts/")({ component: RouteComponent, loader: async () => ({ slugs: await listPostsFn(), }), }); function RouteComponent() { const { slugs } = Route.useLoaderData(); return ( <ul> {slugs.map((slug) => ( <li key={slug}> <Link to="/posts/$slug" params={{ slug }}> {slug} </Link> </li> ))} </ul> ); }src/routes/posts/$slug.tsx— loads one post and renders it withArticle
// src/routes/posts/$slug.tsx import { createFileRoute } from "@tanstack/react-router"; import Article from "@/components/article"; import { getPostFn } from "@/lib/blog.functions"; export const Route = createFileRoute("/posts/$slug")({ component: RouteComponent, loader: async ({ params }) => ({ post: await getPostFn({ data: params.slug }), }), }); function RouteComponent() { const { post } = Route.useLoaderData(); return <Article>{post}</Article>; }- Store post source files as
Verification
- A route rendering
<Article>shows styled headings, lists, and paragraphs (fromtypeset.css) - Fenced code blocks render with syntax highlighting (if step 3 was completed)
Gotchas
highlightCssmust be injected once, globally (root routehead)
References
- TanStack Markdown — parser and React renderer
- TanStack Highlight — syntax highlighting engine
- shadcn Typeset — prose styling for rendered Markdown