Skip to main content
Martin.

A Markdown Blog in TanStack Start With Zero Magic

The blog you're reading right now is powered by two npm packages and the Node.js filesystem API. That's it. No MDX compiler, no content collections, no headless CMS, no build-time content layer.

I'm going to show you the entire thing. It's around 80 lines of code.

Why Not MDX?

MDX is great if you need React components inside your posts. I don't. I need headings, paragraphs, code blocks, links, and the occasional inline HTML. Markdown does all of that. Adding a JSX compiler to the pipeline for features I won't use felt like the wrong trade-off.

There's also a complexity cost. MDX introduces a build step, a bundler plugin, and a mental model where your content files are also code files. When something breaks, you're debugging a compiler, not reading a file.

Plain markdown files are just text. I can open them in any editor, preview them on GitHub, and grep through them in the terminal. That simplicity is worth protecting.

The File Structure

Blog posts live in content/blog/ as .md files. The filename becomes the URL slug:

content/
  blog/
    markdown-blog-tanstack-start.md   → /blog/markdown-blog-tanstack-start
    css-light-dark-function.md         → /blog/css-light-dark-function
    tanstack-start-dark-mode.md        → /blog/tanstack-start-dark-mode

Each file has YAML frontmatter at the top:

---
title: "A Markdown Blog in TanStack Start With Zero Magic"
description: "No MDX, no content layer, no CMS."
date: 2026-02-05
tags:
  - TanStack Start
  - Markdown
---

Your content starts here...

That's the entire content model. No schema files, no config objects, no type generation step.

The Blog Engine: 80 Lines

Two packages do the heavy lifting:

  • gray-matter — parses YAML frontmatter from markdown files
  • marked — converts markdown to HTML

Here's the full utility file:

import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
import { marked } from 'marked'

export interface BlogPost {
  slug: string
  title: string
  description: string
  date: string
  tags?: string[]
  content: string
  htmlContent: string
}

export interface BlogPostMeta {
  slug: string
  title: string
  description: string
  date: string
  tags?: string[]
}

const BLOG_DIR = path.join(process.cwd(), 'content', 'blog')

Two interfaces: BlogPostMeta for listing pages (no body content), and BlogPost for the full thing. The directory path is built from process.cwd() so it works regardless of where the server is running.

Getting All Posts

export function getAllBlogPosts(): BlogPostMeta[] {
  if (!fs.existsSync(BLOG_DIR)) {
    return []
  }

  const files = fs.readdirSync(BLOG_DIR).filter(file => file.endsWith('.md'))

  const posts = files.map(filename => {
    const slug = filename.replace('.md', '')
    const filePath = path.join(BLOG_DIR, filename)
    const fileContent = fs.readFileSync(filePath, 'utf-8')
    const { data } = matter(fileContent)

    return {
      slug,
      title: data.title || slug,
      description: data.description || '',
      date: data.date || '',
      tags: data.tags || [],
    }
  })

  // Sort by date, newest first
  return posts.sort((a, b) => {
    if (!a.date || !b.date) return 0
    return new Date(b.date).getTime() - new Date(a.date).getTime()
  })
}

Read the directory. Filter for .md files. Parse frontmatter. Sort by date. Return metadata only.

The existsSync check at the top means the blog works even if the content/blog directory doesn't exist yet — it just returns an empty array. No crash, no build error. You can add your first post whenever you're ready.

Getting a Single Post

export function getBlogPost(slug: string): BlogPost | null {
  const filePath = path.join(BLOG_DIR, `${slug}.md`)

  if (!fs.existsSync(filePath)) {
    return null
  }

  const fileContent = fs.readFileSync(filePath, 'utf-8')
  const { data, content } = matter(fileContent)

  return {
    slug,
    title: data.title || slug,
    description: data.description || '',
    date: data.date || '',
    tags: data.tags || [],
    content,
    htmlContent: marked(content) as string,
  }
}

Same pattern, but now we also grab content (the raw markdown body) and run it through marked() to get HTML. The raw content is kept around in case you ever want to do something with it — word count, reading time, whatever.

That's the entire engine.

Wiring It to TanStack Start

The Listing Page

The blog index uses a server function to fetch all posts at request time:

import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { getAllBlogPosts } from '../../utils/blog'

const fetchBlogPosts = createServerFn({ method: 'GET' }).handler(async () => {
  return getAllBlogPosts()
})

export const Route = createFileRoute('/blog/')({
  loader: async () => fetchBlogPosts(),
  component: BlogPage,
})

The loader runs on the server. It reads the filesystem, parses the markdown, and sends the metadata to the client. The client never touches fs or gray-matter.

The Post Page

Individual posts use a dynamic route with $slug:

import { createFileRoute, notFound } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'
import { getBlogPost } from '../../utils/blog'

const fetchBlogPost = createServerFn({ method: 'GET' })
  .inputValidator((slug: string) => slug)
  .handler(async ({ data: slug }) => {
    const post = getBlogPost(slug)
    if (!post) {
      throw notFound()
    }
    return post
  })

export const Route = createFileRoute('/blog/$slug')({
  loader: async ({ params }) => fetchBlogPost({ data: params.slug }),
  component: BlogPostPage,
})

If the slug doesn't match a file, getBlogPost returns null, and we throw notFound() which renders a 404. No try/catch, no error boundaries for this case — TanStack handles it.

Rendering the HTML

The post page renders the pre-parsed HTML with dangerouslySetInnerHTML:

<div
  className="prose-custom"
  dangerouslySetInnerHTML={{ __html: post.htmlContent }}
/>

Yes, dangerouslySetInnerHTML. The name sounds scary, but it's fine here — we control the source files. Nobody is injecting content through a form or an API. The markdown files are in our repo, reviewed in PRs, and parsed by marked. There's no XSS vector.

The prose-custom class handles all the typography styling — heading sizes, paragraph spacing, link colours, code block formatting. That CSS is defined once and applies to any markdown content.

What About Tag Filtering?

Tags are just an array in the frontmatter. The blog listing page reads a tag search parameter from the URL and filters client-side:

const { tag } = Route.useSearch()

const filteredPosts = tag
  ? posts.filter((post) => post.tags?.includes(tag))
  : posts

Each tag on a post page links to /blog?tag=React (or whatever). That's the entire tagging system. No tag index page, no tag archive, no tag cloud. If I need those later, they're a few lines of code away.

What This Doesn't Do

Let's be honest about the trade-offs:

  • No syntax highlighting. Code blocks are styled but not tokenized. I could add highlight.js or shiki if I wanted, but plain styled <pre> blocks are fine for now.
  • No image optimisation. Images in posts are standard <img> tags. No lazy loading, no responsive sizes, no blur placeholders.
  • No RSS feed. Easy to add — getAllBlogPosts() gives you everything you need to generate one — but I haven't bothered yet.
  • No incremental builds. Every request reads from the filesystem. On a five-post blog, this takes under a millisecond. I'll optimise when it matters.

Every one of these is a feature I could add in an afternoon. The point is I don't need them now, and the architecture doesn't prevent me from adding them later.

Why This Works

The entire blog — reading, parsing, rendering, routing, tagging — is about 200 lines of code across four files. There's no configuration to maintain, no schema to keep in sync, no build plugin to update when the framework changes.

When I want to write a post, I create a markdown file. When I want to publish it, I push to main. That's it.

Sometimes the simplest solution is the right one.