TanStack Start

A blog, end to end: config, server functions and routes.

View as Markdown

A blog with posts and authors, parsed at build time and read through server functions. This site is built the same way. For a smaller one with posts only, open the example in StackBlitz.

The content

Two folders: content/posts and content/authors. A post names its author by slug, so content/posts/hello-world.md is:

content/posts/hello-world.md
---
title: Hello world
author: ada
publishedAt: 2026-09-16
---

The first post.

The config

transform parses each post's Markdown once, at build time, so a page only renders the result. references says that a post's author is an author's slug, and the build fails on one that doesn't exist.

tomekit.config.ts
import { parseMarkdown } from "@tanstack/markdown/parser";
import { defineConfig, directory } from "tomekit";
import { z } from "zod";

export default defineConfig({
  collections: {
    authors: {
      loader: directory("content/authors"),
      schema: z.strictObject({ name: z.string() }),
    },
    posts: {
      loader: directory("content/posts"),
      schema: z.strictObject({
        author: z.string(),
        draft: z.boolean().default(false),
        publishedAt: z.coerce.date(),
        title: z.string(),
      }),
      transform: ({ body, metadata }, { dev, skip }) =>
        metadata.draft && !dev
          ? skip("draft")
          : { body: parseMarkdown(body, { headingIds: true }) },
    },
  },
  references: { posts: { author: "authors" } },
});

A draft is skipped everywhere except dev, so it is not in documents(), not in slugs() and not in the generated types.

The server functions

A collection holds all of its documents, so keep it on the server: a route that imports it ships them to the browser. Read it in a server function and return only what the page renders.

src/server/posts.ts
import { notFound } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { authors, posts } from "tomekit/content";

const getPosts = createServerFn({ method: "GET" }).handler(() =>
  posts
    .documents()
    .toSorted(
      (a, b) =>
        b.metadata.publishedAt.getTime() - a.metadata.publishedAt.getTime()
    )
    .map(({ metadata, slug }) => ({ slug, title: metadata.title }))
);

const getPost = createServerFn({ method: "GET" })
  .validator((slug: string) => slug)
  .handler(({ data: slug }) => {
    const post = posts.get(slug);

    if (!post) {
      throw notFound();
    }

    const author = authors.get(post.metadata.author);

    return { ...post, author: author.metadata.name };
  });

export { getPost, getPosts };

get(slug) takes a plain string, so it may return undefined. get(post.metadata.author) doesn't: the field is typed as an author's slug, because the reference was checked at build time.

The routes

src/routes/index.tsx
import { createFileRoute, Link } from "@tanstack/react-router";

import { getPosts } from "#/server/posts";

export const Route = createFileRoute("/")({
  loader: () => getPosts(),
  component: Posts,
});

function Posts() {
  return (
    <ul>
      {Route.useLoaderData().map(({ slug, title }) => (
        <li key={slug}>
          <Link params={{ slug }} to="/posts/$slug">
            {title}
          </Link>
        </li>
      ))}
    </ul>
  );
}

The body is already a parsed document, so the page renders it without a Markdown parser in the bundle.

src/routes/posts.$slug.tsx
import { Markdown } from "@tanstack/markdown/react";
import { createFileRoute } from "@tanstack/react-router";

import { getPost } from "#/server/posts";

export const Route = createFileRoute("/posts/$slug")({
  loader: ({ params }) => getPost({ data: params.slug }),
  head: ({ loaderData }) => ({ meta: [{ title: loaderData?.metadata.title }] }),
  component: Post,
});

function Post() {
  const { author, body, metadata } = Route.useLoaderData();

  return (
    <article>
      <h1>{metadata.title}</h1>
      <p>{author}</p>
      <Markdown>{body}</Markdown>
    </article>
  );
}

Prerendering

Every post is known before the server starts, so every page can be rendered at build time. Let the crawler follow the links from the index page:

vite.config.ts
export default defineConfig({
  plugins: [
    tomekit(),
    tanstackStart({ prerender: { crawlLinks: true, enabled: true } }),
  ],
});

To list the paths yourself instead, import posts in vite.config.ts and run Vite with tomekit/register, as Register shows.

A prerendered page still calls its server function when a link opens it in the browser. To host the blog as static files, with no server at all, save each server function's result during prerendering with @tanstack/start-static-server-functions:

src/start.ts
import { createStart } from "@tanstack/react-start";
import { staticFunctionMiddleware } from "@tanstack/start-static-server-functions";

export const startInstance = createStart(() => ({
  functionMiddleware: [staticFunctionMiddleware],
}));

Only the calls made while prerendering are saved. In the browser, a call with any other input fails with an error instead of notFound(), so link only to pages the crawler reaches.