Implementing a Simple Blog with TanStack Start
updated:
The first two posts on this site were hand-written as individual TanStack Start routes. Fine for a quick start, but not scalable. Since I plan to share more web development tips and experiments, I needed a more flexible setup. At the same time, this is a hobby project, and I didn’t want to sink hours into building a full-fledged blogging platform. All I wanted was a simple way to write posts in Markdown and render them dynamically on my site. Here’s the approach I took.
Requirements
- Write posts in Markdown, including frontmatter,
- Support syntax highlighting for code snippets,
- Skip a dedicated API and keep everything in two Server Functions: one for listing posts, and one for fetching an individual post.
- Extract any custom CSS to stay compatible with my strict Content Security Policy,
- Avoid a database or (pre)build steps.
If you’re looking for a fully featured blogging platform, this won’t be it. The approach here is perfect for projects where blogging isn’t the main focus, but you still want an easy way to publish posts or release notes.
For larger setups, I’d recommend a dedicated static site generator like Eleventy, a personal favorite.
Step 1: Project Structure
I started by adding a src/posts folder to hold my Markdown files. Each post is identified by filename, and the slug in the URL matches the file name (minus the extension).
For example, visiting /blog/implementing-a-simple-blog-with-tanstack-start loads: src/posts/implementing-a-simple-blog-with-tanstack-start.md.
---
title: Post Title
description: This is a sample post
---
Content Here.
<!-- excerpt -->
More Content Here.Step 2: Indexing Posts
Next, I created a src/posts/worker.ts file to index all posts and expose the two Server Functions. Using Vite’s glob imports, I can load all Markdown files when the server starts. It’s a greedy approach, but perfect for my small number of posts: all Markdown to HTML conversion happens once during startup.
This wouldn’t scale to hundreds of posts since everything is held in memory, but it’s ideal for lightweight personal sites.
// Import the raw text content for all posts.
const modules = import.meta.glob<string>("./*.md", {
import: "default",
query: "?raw",
});
// For each module, we need to parse the post.
const posts = await Promise.all(
Object.entries(modules).map(async ([filename, module]) => {
const value = await module(); // Raw text content as string.
return markdownToHtml(value, filename);
}),
);Step 3: Converting Markdown to HTML
With the indexing in place, I needed a utility to convert Markdown to HTML. I used remark, a powerful tool for transforming Markdown through plugins. This setup lets me easily add support for frontmatter, syntax highlighting, and more.
Side-Step: Excerpts
I wanted each post to include an excerpt (usually the first paragraph) for use in the listing view. There are plugins for this, but for my simple use case, I didn’t need one. I used simple string manipulation on the post content to determine where to truncate the post based on the excerpt marker. Everything above this marker becomes the excerpt.
Side-Step: Syntax Highlighting
Syntax highlighting can get tricky. I wanted it fully server-side to avoid bloating the client bundle, so I went with Shiki. It’s feature-rich but requires a few tweaks to play nicely with Cloudflare.
A second challenge: most syntax highlighters use inline styles, which conflict with my CSP. I needed class-based styling and a single <style> block that I could inject in the document head.
<!-- This violates CSP and will not render the text red. -->
<span style="color: red">snippet</span>After some experimenting with Shiki transformers, I got it working. The generated CSS defines a set of CSS variables, which you will have to apply yourself.
Putting it all together, the markdownToHtml utility outputs a VFile object that includes the post’s frontmatter, extracted CSS, and rendered HTML.
import theme from "@shikijs/themes/solarized-light";
import { transformerStyleToClass } from "@shikijs/transformers";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeStringify from "rehype-stringify";
import remarkFrontmatter from "remark-frontmatter";
import remarkParse from "remark-parse";
import remarkParseFrontmatter from "remark-parse-frontmatter";
import remarkRehype from "remark-rehype";
import { createHighlighter } from "shiki";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { unified } from "unified";
const EXCERPT_BOUNDARY = "<!-- excerpt -->";
function process(
value: string,
filename: string,
initialData?: Record<string, unknown>,
) {
const extractCss = transformerStyleToClass();
return unified()
.use(remarkFrontmatter)
.use(remarkParseFrontmatter)
.use(remarkParse)
.use(remarkRehype)
.use(rehypePrettyCode, {
getHighlighter: (options) =>
createHighlighter({
...options,
engine: createJavaScriptRegexEngine(),
themes: [theme],
}),
grid: false, // Not CSP compatible.
theme: { light: "solarized-light" },
transformers: [extractCss],
})
.use(rehypePrettyCode)
.use(rehypeStringify)
.process({
data: { ...initialData, css: extractCss },
path: filename,
value,
});
}
export async function markdownToHtml(value: string, filename: string) {
const excerpt = value.substring(0, value.indexOf(EXCERPT_BOUNDARY));
return process(value, filename, {
excerpt: await process(excerpt, filename),
});
}Step 4: Exposing Server Functions
Back in worker.ts, I now had a fully processed posts array. Next, I defined two Server Functions: one to fetch all posts, and another for a single post.
Fetch All Posts
For the index page, I needed a function that returns all posts with excerpts instead of full content. Each post entry includes data, HTML, CSS, and a slug, making it easy for the route component to deep-link into individual posts.
Sorting and pagination aren’t included here for brevity but would be recommended for predictable results.
import { createServerFn } from "@tanstack/react-start";
export const getPosts = createServerFn().handler(() => {
return posts.map((post) => ({
data: post.data.frontmatter,
css: post.data.excerpt?.data.css?.getCSS() ?? "",
html: String(post.data.excerpt),
slug: post.stem,
}));
});Fetch a Single Post
For individual posts, the function verifies that the requested slug matches one of the indexed posts. Luckily, TanStack Start’s Server Functions support input validation, so it’s easy to ensure valid requests.
import { notFound } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
export const getPost = createServerFn()
.inputValidator((slug: string) => {
const post = posts.find((post) => post.stem === slug);
if (!post) {
throw notFound();
}
return post;
})
.handler(({ data: post }) => ({
data: post.data.frontmatter,
css: post.data.css?.getCSS() ?? "",
html: String(post),
slug: post.stem,
}));Step 5: Fetching Data in Loaders
At this point, all posts are indexed and rendered server-side. The final step was wiring up the components. Each post defines its title and description in frontmatter. The layout uses Tailwind’s Typography Plugin for styling.
Listing Page
The listing page displays post excerpts with links to full posts. These links assume a route like src/routes/blog/$slug.tsx.
Remember the syntax highlighting CSS we extracted earlier? Now’s the time to apply it by appending it to the document head. Separately, the CSS variables are applied to the code blocks by using Tailwind’s descendant selectors.
import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
import { getPosts } from "@/posts/worker";
export const Route = createFileRoute("/")({
loader: async () => ({ posts: await getPosts() }),
head: ({ loaderData }) => ({
styles: loaderData?.posts
.filter((post) => Boolean(post.css)) // Omit empty styles.
.map((post) => ({ children: post.css })),
}),
component: RouteComponent,
});
function RouteComponent() {
const posts = useLoaderData({
from: "/",
select: (state) => state.posts,
});
return (
<ul className="prose">
{posts.map((post) => (
<li key={post.slug}>
<h1>
<Link params={{ slug: post.slug }} to="/blog/$slug">{post.data.title}</Link>
</h1>
<div
className="prose-pre:bg-[var(--shiki-light-bg)] prose-pre:**:text-[var(--shiki-light)]"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
</li>
))}
</ul>
);
}Single Posts
The single post route is a splat route, capturing the remaining path after /blog/. It fetches the post by slug and sets the appropriate document title and meta tags based on the frontmatter.
import { createFileRoute, useLoaderData } from "@tanstack/react-router";
import { getPost } from "@/posts/worker";
export const Route = createFileRoute("/blog/$slug")({
loader: async ({ params }) => ({
post: await getPost({ data: params.slug }),
}),
head: ({ loaderData }) => ({
meta: [
{ title: loaderData?.post.data?.title },
{ name: "description", content: loaderData?.post.data?.description },
],
styles: [{ children: loaderData?.post.css }],
}),
component: RouteComponent,
});
function RouteComponent() {
const post = useLoaderData({
from: "/blog/$slug",
select: (state) => state.post,
});
return (
<article className="prose">
<h1>{post.data.title}</h1>
<div
className="prose-pre:bg-[var(--shiki-light-bg)] prose-pre:**:text-[var(--shiki-light)]"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
</article>
);
}Conclusion
And that’s it. This simple approach is used in this site to render its posts. It’s not a full-featured CMS, but it’s clean, flexible, and just powerful enough for small projects.
Future iterations might include draft support or better filtering, but for now, I’m happy with how it turned out. Keeping everything inside TanStack Start feels like a win, as I think it’s shaping up to be one of the best frameworks for modern React apps.
