Building a Blog in TanStack (Part 1 of 2)

Adam Rackis Adam Rackis on

TanStack Start is one of the newest web frameworks, and its popularity is rising quickly. Start is a thin server-side layer that sits atop TanStack Router and provides features like server functions, API endpoints, and server-side rendering. I wrote a three-part introduction to Router and an introduction to Start.

This post will be a bit different. We’ll explore TanStack start via a more traditional, old-school use case: we’ll implement a blog (you can see the complete thing on GitHub). It’s somewhat of a cliche, but it will let us explore important features, such as server functions and routing parameters, as well as niche patterns, such as static pre-rendering.

Here in part 1, we’ll implement our blog. Then, in part 2, we’ll explore static generation in order to deploy it in the most sensible way. Stay tuned for that!

Setting Up

We’ll write our blog posts in Markdown files and scan the appropriate directory to discover the posts we have, so we can generate links to them. Then, for the page that displays an individual blog post, we’ll parse the Markdown content and generate HTML with code highlighting.

Finding the Posts

As a good first step, we’ll need to read all our blog posts. These posts are under the blog folder, in eponymous folders, each with an index.md.

A visual representation of a folder structure for a blog site, showing a 'src' folder containing a 'blog' folder with two subfolders 'post-1' and 'post-2', each containing an 'index.md' file.

We just want the names of these posts so we can generate links on our homepage. Vite actually has a nice import.meta.glob method to read in all files in a dynamic way.

const allPosts: Record<string, any> = import.meta.glob(
  "../blog/**/*.md", 
  { query: "?raw", eager: true }
);Code language: TypeScript (typescript)

From there, we can inspect the URL of each .md file we find and get the correct name. Here’s the entire method for this.

export const getAllBlogPosts = () => {
  const allPosts: Record<string, any> = import.meta.glob("../blog/**/*.md", { query: "?raw", eager: true });

  return Object.entries(allPosts).reduce(
    (result, [key, module]) => {
      const paths = key.split("/");
      const slug = paths.at(-2)!;

      result[slug] = module.default;
      return result;
    },
    {} as Record<string, string>,
  );
};Code language: TypeScript (typescript)

Reading Metadata About Each Blog Post

We’ll use gray-matter to read metadata from our blog posts.

import matter from "gray-matter";Code language: JavaScript (javascript)

This will allow us to put metadata at the top of our Markdown blog files.

---
title: Post 1
date: "2025-12-05T10:00:00.000Z"
description: Post 1
---Code language: Markdown (markdown)

From that, we can get the title, date, and description. We’ll whip up some types and helpers for this data.

export type PostMetadata = {
  title: string;
  date: string;
  description: string;
  slug: string;
  author: string;
  ogImage: string;
  coverImage: string;
};

export type Post = PostMetadata & {
  content: string;
};Code language: TypeScript (typescript)
const metadataFields: (keyof PostMetadata)[] = ["title", "date", "description", "slug", "author", "ogImage", "coverImage"];
const postFields: (keyof Post)[] = [...metadataFields, "content"];Code language: TypeScript (typescript)

And a function to read the metadata for a single blog post.

export function getPostMetadata(slug: string, fileContents: string): PostMetadata {
  const { data } = matter(fileContents);

  const result: PostMetadata = {
    slug,
  } as PostMetadata;

  // Ensure only the minimal needed data is exposed
  metadataFields.forEach(field => {
    if (typeof data[field] !== "undefined") {
      result[field] = data[field];
    }
  });

  return result;
}Code language: JavaScript (javascript)

Building the Homepage

Let’s build the main page for our blog. 

Here’s the route for our root index (/) path. We’ve defined a loader, as well as specified the component we want rendered. The loader will read the titles and metadata for each post. Then our component will render links for each.

export const Route = createFileRoute("/")({
  loader: async () => {
    const posts = await getAllPosts();
    return {
      posts,
    };
  },
  component: App,
});Code language: JavaScript (javascript)

Don’t let the boilerplate details here scare you. Even without AI, the standard TanStack file watcher that runs in dev mode will generate a minimal route object with the correct path whenever you add a new file in the routes folder.

Our loader reads our posts. Then we connect up a React component for this route. Let’s look at each, in turn.

If you’re thinking our loader can just call those utility methods we looked at before, well, not so fast. Those methods were reading file contents on disk. That’s all well and good, but in TanStack Start, our loaders are isomorphic. When you first browse to your website, that initial page will run its loader on the server, and the server will render your React component. Any subsequent time you browse to any page, that loader will run on the client, in your user’s browser. That means there’s no way we can run Node APIs to read file contents.

The solution is to use a Server Function. The docs are here, but the short version is that a TanStack Server Function is a function you define that always runs on the server. If you call a server function from a server-only location, such as an API endpoint, another server function, or even a route loader running on the server, TanStack will simply invoke it. And if you call a Server Function from the client, TanStack will do the legwork of firing off the correct network request.

This call below is a Server Function:

const posts = await getAllPosts();Code language: TypeScript (typescript)

Let’s look at its definition.

const getAllPosts = createServerFn().handler(async () => {
  const postContentLookup = getAllBlogPosts();

  const blogPosts = Object.entries(postContentLookup).map(([slug, content]) => getPostMetadata(slug, content));

  const allPosts: PostMetadata[] = blogPosts
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
  return allPosts;
});Code language: TypeScript (typescript)

We get all the posts on disk, then read the metadata for each. This is a server function, so it will always run on the server, no matter where the route’s loader runs.

We won’t show the entire route component, but we will read the data from the loader.

function App() {
  const { posts } = Route.useLoaderData();Code language: TypeScript (typescript)

Later, we’ll loop it and emit links for each blog post.

<div>
  {posts.map(post => (
    <div key={post.title} className="blog-list-item">
      <h1>
        <Link to={`/blog/$slug`} params={{ slug: post.slug }}>
          {post.title}
        </Link>
      </h1>
      <small>
        <DateFormatter dateString={post.date}></DateFormatter>
      </small>
      <p>{post.description}</p>
    </div>
  ))}
</div>Code language: TypeScript (typescript)

We can see it works!

Rendering Each Blog Post

Let’s get a route created to render an individual blog post. As the <Link> component from the homepage implied, we want our URLs of the form /blog/title-of-post. Obviously title-of-post is dynamic, so we’ll need a route variable. In TanStack, we do this by first creating a folder called blog inside of our routes folder, and then inside of that, we create a $slug.tsx file.

The dollar sign in front of $slug means that $slug is actually a route parameter, which will be replaced with whatever is in the URL at that location.

As before, the TanStack file watcher will scaffold a minimal route for us.

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/blog/$slug")({
  component: RouteComponent,
});

function RouteComponent() {
  return <div>Hello "/junk/$slug"!</div>;
}Code language: TypeScript (typescript)

Let’s fill it out with some details.

export const Route = createFileRoute("/blog/$slug")({
  loader: async ({ params }) => {
    return await getPostContent({ data: { slug: params.slug } });
  },
  head: ({ params }) => {
    return {
      meta: [
        {
          title: `${params.slug} | Adam Rackis's blog`,
        },
      ],
    };
  },
  component: RouteComponent,
});Code language: TypeScript (typescript)

We add a loader that again calls a Server Function we’ll create in a moment. The loader takes a params object we can destructure, which contains the value of our path parameter, which we named $slug. This was because our route was named $slug.tsx.

Then we have a head function which allows us to set a title for this page (among other things), and like the loader, allows us access to the path params.

Now let’s look at our server function that grabs our post’s content, for whatever slug we have in our url.

export const getPostContent = createServerFn()
  .inputValidator((data: { slug: string }) => data)
  .handler(async ({ data }) => {
    const postContentLookup = getAllBlogPosts();

    if (!postContentLookup[data.slug]) {
      throw new Error(`Post not found: ${data.slug}`);
    }

    const post = await getPost(data.slug, postContentLookup[data.slug]);

    return { post };
  });Code language: TypeScript (typescript)

We get the entire list of all blog posts, and verify that the one we want is in there. If it is, we call getPost and return the result. Let’s take a look at getPost now

export async function getPost(slug: string, fileContents: string): Promise<Post> {
  const { data, content: markdownContent } = matter(fileContents);
  const content = await markdownToHtml(markdownContent);

  const result: Post = {
    slug,
    content,
  } as Post;

  // Ensure only the minimal needed data is exposed
  postFields.forEach(field => {
    if (typeof data[field] !== "undefined") {
      result[field] = data[field];
    }
  });

  return result;
}Code language: TypeScript (typescript)

The real work happens in markdownToHtml which converts our Markdown to HTML.

I decided to use markdown-it along with Shiki, but there are endless options out there. Here’s what it looks like.

import Shiki from "@shikijs/markdown-it";
import MarkdownIt from "markdown-it";

const markdownIt = MarkdownIt({
  html: true,
}).use(
  await Shiki({
    themes: {
      light: "dark-plus",
      dark: "dark-plus",
    },
    transformers: [
      {
        name: "line-numbers-pre",
        preprocess: (_: string, options: any) => {
          if (options?.meta?.__raw?.includes("line-numbers")) {
            options.attributes = {};
            options.attributes.lineNumbers = true;
          }
        },
      },
      {
        name: "line-numbers-post",
        postprocess: (html, options: any) => {
          if (options?.attributes?.lineNumbers) {
            return html.replace(/<pre /g, "<pre data-linenumbers ");
          }
          return html;
        },
      },
    ],
  }),
);

export default async function markdownToHtml(markdown: string) {
  return markdownIt.render(markdown);
}Code language: TypeScript (typescript)

The vast majority of this setup was for a custom transformer that allows these special keywords after the triple backticks in Markdown.

```sql line-numbers
SELECT id, SUM(amount)
FROM some_table st
JOIN other_table ot
ON st.id = ot.id
WHERE active = true
GROUP BY ot.id
```Code language: Markdown (markdown)

This then adds a data-linenumbers attribute to the pre element

<pre data-linenumbers="">Code language: HTML, XML (xml)

And this attribute allows me to render line numbers via a CSS counter.

pre[data-linenumbers] code {
  counter-reset: step;
  counter-increment: step 0;
}

pre[data-linenumbers] code .line::before {
  content: counter(step);
  counter-increment: step;
  width: 1rem;
  margin-right: 1rem;
  display: inline-block;
  text-align: right;
  color: rgba(115, 138, 148, 0.4);
}Code language: CSS (css)

I previously blogged about CSS counters, and of course, the complete code for this sample blog is on GitHub.

Now we can see our post.

And our line numbers work:

On to Part 2

Our blog is set up and working. In Part 2 (coming soon!), we’ll look at some simple tricks for deploying our blog as a static site with no server dependencies.

Master the Full Stack

2 responses to “Building a Blog in TanStack (Part 1 of 2)”

  1. Fyodor says:

    Interesting, but FWIW, this is a helluva hammer for such a tiny nail, isn’t it? I mean, that’s not what TanStack Start is [idiomatically] for. Or is that?

    • Adam Rackis Adam Rackis says:

      I’d say it’s one more tool to keep around. If you’re already familiar with, and love TanStack Start, and need to build something static, this can be an incredibly efficient way to do it.

Leave a Reply

Your email address will not be published. Required fields are marked *

$966,000

Frontend Masters donates to open source projects through thanks.dev and Open Collective, as well as donates to non-profits like The Last Mile, Annie Canons, and Vets Who Code.