DEV Community

Chocolate Rooibos
Chocolate Rooibos

Posted on • Originally published at chocolaterooibos.dev

How to build a blog with TanStack Start and Content Collections

When most devs consider building out a content site, their first inclination is often to reach for Astro, and for good reason. Astro comes with more or less everything you need to build out a content site.

Support for building content pages with Markdown/MDX and content collections is built into the framework. Astro even has templates to quickly scaffold a blog-style site.

However, TanStack Start has recently seen explosive growth, and there are some situations in which a Start app is preferable to Astro, even for publishing written content. You might have an existing Start app and want to add content pages. Or perhaps you just like staying within a pure React ecosystem.

The good news is that, whatever your reasons, it's quite easy to build a blog or content site using TanStack Start. In fact, this site is built with the exact stack covered in this article.

What we'll cover

In this guide we'll look at how to use the content-collections package alongside YAML frontmatter in MD / MDX files to build a performant and type-safe TanStack Start blog.

This will include configuring a collection for blog posts with Zod validation, setting up a dynamic route to render these collections, and rendering MDX content in React.

In an attempt to keep this guide as on-topic as possible, we won't delve into extraneous subjects such as styling, font configuration, or deployment. However, I highly recommend using Tailwind and Tailwind Typography for styling, as it's free, extremely convenient to set up, and provides beautiful blog post styling out of the box.

For deployment, Cloudflare Workers is one of the best and easiest ways to go, unless you have a very specific reason to prefer a different provider. But for a content site, Cloudflare is often an excellent deployment target.

Project setup

Before setting up any blog content, you'll need a working TanStack Start app. If you already have one, feel free to skip ahead.

To create your Start app, run:

pnpx @tanstack/cli@latest create
Enter fullscreen mode Exit fullscreen mode

And make your way through the setup wizard. This is the method I used when writing this tutorial.

Alternatively (and highly recommended), you can start from a template. See TanStack's docs for more information on this.

Yet another option, if you plan to deploy to Cloudflare, is to use the following command:

pnpm create cloudflare@latest start-blog --framework=tanstack-start
Enter fullscreen mode Exit fullscreen mode

This will scaffold a Start project that's ready to deploy to Cloudflare Workers.

Once the app is created, cd into your app directory and run:

pnpm install
pnpm dev
Enter fullscreen mode Exit fullscreen mode

Set up content-collections

Install dependencies

Now that the app is up and running, you'll need to set up content-collections. First, install all necessary dependencies:

pnpm add -D @content-collections/core @content-collections/vite @content-collections/mdx zod
Enter fullscreen mode Exit fullscreen mode

Update tsconfig.json

Now, add a content-collections path to your tsconfig.json. This allows us to import generated collections into our TypeScript code:

{
  "compilerOptions": {
    // ...
    "paths": {
      "~/*": ["./src/*"],
      "content-collections": ["./.content-collections/generated"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Update vite.config.ts

Next, import the @content-collections/vite plugin and add it to the plugins array in vite.config.ts:

import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'

import { tanstackStart } from '@tanstack/react-start/plugin/vite'

import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
import contentCollections from '@content-collections/vite'

const config = defineConfig({
  resolve: { tsconfigPaths: true },
  plugins: [
    contentCollections(),
    devtools(),
    nitro({ rollupConfig: { external: [/^@sentry\//] } }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
})

export default config
Enter fullscreen mode Exit fullscreen mode

Update .gitignore

If you're using git, it's best to add the generated directory .content-collections to your .gitignore:

.content-collections
Enter fullscreen mode Exit fullscreen mode

Create content-collections.ts

Now it's time to define a collection. To do this, create a content-collections.ts file at the root of your project:

import { defineCollection, defineConfig } from '@content-collections/core'
import { compileMDX } from '@content-collections/mdx'
import { z } from 'zod'

const articleSchema = z.object({
  title: z.string(),
  createdAt: z.iso.date(),
  updatedAt: z.iso.date(),
  description: z.string(),
})

const articles = defineCollection({
  name: 'articles',
  directory: 'content/articles',
  include: '**/*.mdx',
  schema: articleSchema,
  transform: async (document, context) => {
    const mdx = await compileMDX(context, document)
    return { ...document, mdx }
  },
})

export default defineConfig({
  content: [articles],
})
Enter fullscreen mode Exit fullscreen mode

The code above does several things. The Zod schema is used to validate the frontmatter of each article MDX file you create. For example, if your article looks like this:

---
title: "How to build a blog with TanStack Start and Content Collections"
createdAt: Invalid Date
updatedAt: 2026-04-06
description: "This is a test description."
---

...
Enter fullscreen mode Exit fullscreen mode

Zod validation will fail, since createdAt isn't a valid date, and the article won't be added to your generated list of articles. This is extremely helpful because it provides full type safety on the frontmatter. Any article included in the array of article objects content-collections generates is guaranteed to have a valid title, created date, updated date, and description.

The directory option specifies where your files will be stored, and include controls which types of files are processed. In this case, we're only using .mdx files, but if you want to support plain Markdown as well, use **/*.{md,mdx} instead.

Finally, transform processes the article's MDX content and adds an mdx property on the output object, so that we have easy access to the compiled MDX when rendering the post in React.

Create your first blog post

Now that the content-collections infrastructure is in place, it's time to create an article. In the previous step, we configured the articles collection to be stored within the content/articles directory. It's conventional when using content-collections to store all content somewhere within the content directory.

If you haven't already, create a content directory at the root of the project with an articles subdirectory inside. Then add content/articles/test-article.mdx:

---
title: Chocolate Rooibos Tea is the Best Tea
createdAt: 2026-04-06
updatedAt: 2026-04-06
description: This is a test description.
---

## This is an h2

This is a test article. The content is irrelevant.
Enter fullscreen mode Exit fullscreen mode

This can be named anything, but it should use an .mdx suffix, since we defined the collection to process only .mdx files. Also note that we'll use the file name as the URL slug. If you use a different file name in this step, the slug in later steps will differ accordingly.

Create dynamic article routes

Now that you've created an article, it should show up under .content-collections/generated/allArticles.js as long as your dev server is running and your frontmatter is valid. If you don't see it, try restarting your dev server. However, your TanStack Start app doesn't yet know how to display articles.

To add this functionality, create a file within your routes directory. In this case, we'll call it articles.$slug.tsx. This will allow us to display articles at /articles/<slug>. If you'd prefer to display them directly at /<slug>, you can name the file $slug.tsx instead.

Add the following code:

import { MDXContent } from '@content-collections/mdx/react';
import { createFileRoute, notFound } from '@tanstack/react-router';
import { allArticles } from 'content-collections';

export const Route = createFileRoute('/articles/$slug')({
  component: RouteComponent,
  loader: async ({ params: { slug } }) => {
    const article = allArticles.find((article) => article._meta.path === slug);
    if (!article) throw notFound();
    return article;
  },
});

function RouteComponent() {
  const article = Route.useLoaderData();
  return (
    <>
      <h1>{article.title}</h1>
      <p>{article.description}</p>
      <MDXContent code={article.mdx} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This code is fairly straightforward. In the loader, we use allArticles (which is generated by content-collections using the files stored in content/articles) and find the entry whose path matches the current slug.

This means that if you navigate to /articles/test-article-name on your site, this corresponds to a file at content/articles/test-article-name.mdx. Of course, if no such file exists or the frontmatter is invalid, you'll get a Not Found error.

Within the actual component, we render the title as an h1, the description as a p, and the processed MDX content.

Navigate to /articles/test-article (or the appropriate path if you named your MDX file differently), and you'll now be able to see your post. While it may be a bit bare-bones and unstyled, you can verify that your Markdown is indeed being rendered correctly as HTML. Additionally, you can edit the article MDX file and see changes in real time.

Providing SEO Metadata

While not mandatory, you'll likely want to implement at least some basic SEO on the post page. This is easily done within createFileRoute:

import { createFileRoute, notFound } from '@tanstack/react-router'
import { allArticles } from 'content-collections'

export const Route = createFileRoute('/articles/$slug')({
  component: RouteComponent,
  loader: async ({ params: { slug } }) => {
    const article = allArticles.find((article) => article._meta.path === slug)
    if (!article) throw notFound()
    return article
  },
  head: async ({ loaderData: article }) => {
    if (!article) return {}
    return {
      meta: [
        {
          title: article.title,
          description: article.description,
        },
      ],
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

Enable prerendering

For optimal performance, you'll want to prerender your TanStack Start site at build time. This is easily done by enabling the prerender option for the tanstackStart plugin in vite.config.ts:

const config = defineConfig({
  ...
  plugins: [
    ...
    tanstackStart({
      prerender: {
        enabled: true,
      },
    }),
    ...
  ],
})

export default config
Enter fullscreen mode Exit fullscreen mode

Next steps

After working through this tutorial, you should now have a fully-functional TanStack Start app with all of the core infrastructure in place for creating prerendered blog posts from MDX files. However, there's still plenty of room for improvement.

For a more robust system, you'll probably want to build a styled ArticlePage component or similar that takes an Article as input and renders out the title, description, dates, and any other metadata, as well as the MDX content. Styling your MDX content with Tailwind Typography is another excellent way to quickly improve the visual quality of your posts.

For more complex use cases such as code syntax highlighting, you may want to use remark or rehype plugins in your MDX compilation step. See the content-collections docs for more information on this.

In many cases, your content site may have a variety of different article types that may require slightly different metadata or different categories. For this, you can define a base Zod schema with all core metadata, and then create more specific article schemas by extending this:

import { defineCollection, defineConfig } from '@content-collections/core'
import { compileMDX } from '@content-collections/mdx'
import { z } from 'zod'

const articleSchema = z.object({
  title: z.string(),
  createdAt: z.iso.date(),
  updatedAt: z.iso.date(),
  description: z.string(),
})

const softwareArticleSchema = articleSchema.extend({
  categories: z.array(z.enum(['react', 'tailwind', 'tanstackStart'])),
})

const softwareArticles = defineCollection({
  name: 'softwareArticles',
  directory: 'content/articles/software',
  include: '**/*.mdx',
  schema: softwareArticleSchema,
  transform: async (document, context) => {
    const mdx = await compileMDX(context, document)
    return { ...document, mdx }
  },
})

export default defineConfig({
  content: [softwareArticles],
})
Enter fullscreen mode Exit fullscreen mode

The important part is that the core content pipeline is now in place: content-collections gives you typed article data, and TanStack Start turns that data into routes, metadata, and content.

Top comments (0)