{"id":9303,"date":"2026-04-17T08:18:19","date_gmt":"2026-04-17T13:18:19","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=9303"},"modified":"2026-04-17T08:18:20","modified_gmt":"2026-04-17T13:18:20","slug":"building-a-blog-in-tanstack-part-1-of-2","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/building-a-blog-in-tanstack-part-1-of-2\/","title":{"rendered":"Building a Blog in TanStack (Part 1 of 2)"},"content":{"rendered":"\n<p><a href=\"https:\/\/tanstack.com\/start\/latest\">TanStack Start<\/a> 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 href=\"https:\/\/frontendmasters.com\/blog\/introducing-tanstack-router\/\">a three-part introduction to Router<\/a> and <a href=\"https:\/\/frontendmasters.com\/blog\/introducing-tanstack-start\/\">an introduction to Start<\/a>.<\/p>\n\n\n\n<p>This post will be a bit different. We&#8217;ll explore TanStack start via a more traditional, old-school use case: we&#8217;ll implement a blog (you can see t<a href=\"https:\/\/github.com\/arackaf\/tanstack-blog-blog-post\">he complete thing on GitHub<\/a>). It&#8217;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.<\/p>\n\n\n\n<p>Here in part 1, we&#8217;ll implement our blog. Then, in part 2, we&#8217;ll explore static generation in order to deploy it in the most sensible way. Stay tuned for that!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"setting-up\">Setting Up<\/h2>\n\n\n\n<p>We&#8217;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&#8217;ll parse the Markdown content and generate HTML with code highlighting.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"finding-the-posts\">Finding the Posts<\/h3>\n\n\n\n<p>As a good first step, we&#8217;ll need to read all our blog posts. These posts are under the blog folder, in eponymous folders, each with an&nbsp;<code>index.md<\/code>.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"296\" height=\"276\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img1.png?resize=296%2C276&#038;ssl=1\" alt=\"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.\" class=\"wp-image-9310\" style=\"width:248px;height:auto\"\/><\/figure>\n<\/div>\n\n\n<p>We just want the names of these posts so we can generate links on our homepage. Vite actually has a nice&nbsp;<code>import.meta.glob<\/code>&nbsp;method to read in all files in a dynamic way.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">const<\/span> allPosts: Record&lt;<span class=\"hljs-built_in\">string<\/span>, <span class=\"hljs-built_in\">any<\/span>&gt; = <span class=\"hljs-keyword\">import<\/span>.meta.glob(\n  <span class=\"hljs-string\">\"..\/blog\/**\/*.md\"<\/span>, \n  { query: <span class=\"hljs-string\">\"?raw\"<\/span>, eager: <span class=\"hljs-literal\">true<\/span> }\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>From there, we can inspect the URL of each <code>.md<\/code> file we find and get the correct name. Here&#8217;s the entire method for this.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> getAllBlogPosts = <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n  <span class=\"hljs-keyword\">const<\/span> allPosts: Record&lt;<span class=\"hljs-built_in\">string<\/span>, <span class=\"hljs-built_in\">any<\/span>&gt; = <span class=\"hljs-keyword\">import<\/span>.meta.glob(<span class=\"hljs-string\">\"..\/blog\/**\/*.md\"<\/span>, { query: <span class=\"hljs-string\">\"?raw\"<\/span>, eager: <span class=\"hljs-literal\">true<\/span> });\n\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-built_in\">Object<\/span>.entries(allPosts).reduce(\n    <span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">result<\/span>, &#91;<span class=\"hljs-params\">key<\/span>, <span class=\"hljs-params\">module<\/span>]<\/span>) =&gt;<\/span> {\n      <span class=\"hljs-keyword\">const<\/span> paths = key.split(<span class=\"hljs-string\">\"\/\"<\/span>);\n      <span class=\"hljs-keyword\">const<\/span> slug = paths.at(<span class=\"hljs-number\">-2<\/span>)!;\n\n      result&#91;slug] = <span class=\"hljs-built_in\">module<\/span>.<span class=\"hljs-keyword\">default<\/span>;\n      <span class=\"hljs-keyword\">return<\/span> result;\n    },\n    {} <span class=\"hljs-keyword\">as<\/span> Record&lt;<span class=\"hljs-built_in\">string<\/span>, <span class=\"hljs-built_in\">string<\/span>&gt;,\n  );\n};<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\" id=\"reading-metadata-about-each-blog-post\">Reading Metadata About Each Blog Post<\/h3>\n\n\n\n<p>We&#8217;ll use&nbsp;<a href=\"https:\/\/www.npmjs.com\/package\/gray-matter\"><code>gray-matter<\/code><\/a>&nbsp;to read metadata from our blog posts.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> matter <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"gray-matter\"<\/span>;<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This will allow us to put metadata at the top of our Markdown blog files.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"Markdown\" data-shcb-language-slug=\"markdown\"><span><code class=\"hljs language-markdown\">---\ntitle: Post 1\ndate: \"2025-12-05T10:00:00.000Z\"\n<span class=\"hljs-section\">description: Post 1\n---<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Markdown<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">markdown<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>From that, we can get the title, date, and description. We&#8217;ll whip up some types and helpers for this data.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">type<\/span> PostMetadata = {\n  title: <span class=\"hljs-built_in\">string<\/span>;\n  date: <span class=\"hljs-built_in\">string<\/span>;\n  description: <span class=\"hljs-built_in\">string<\/span>;\n  slug: <span class=\"hljs-built_in\">string<\/span>;\n  author: <span class=\"hljs-built_in\">string<\/span>;\n  ogImage: <span class=\"hljs-built_in\">string<\/span>;\n  coverImage: <span class=\"hljs-built_in\">string<\/span>;\n};\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">type<\/span> Post = PostMetadata &amp; {\n  content: <span class=\"hljs-built_in\">string<\/span>;\n};<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">const<\/span> metadataFields: (keyof PostMetadata)&#91;] = &#91;<span class=\"hljs-string\">\"title\"<\/span>, <span class=\"hljs-string\">\"date\"<\/span>, <span class=\"hljs-string\">\"description\"<\/span>, <span class=\"hljs-string\">\"slug\"<\/span>, <span class=\"hljs-string\">\"author\"<\/span>, <span class=\"hljs-string\">\"ogImage\"<\/span>, <span class=\"hljs-string\">\"coverImage\"<\/span>];\n<span class=\"hljs-keyword\">const<\/span> postFields: (keyof Post)&#91;] = &#91;...metadataFields, <span class=\"hljs-string\">\"content\"<\/span>];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p id=\"reading-metadata-about-each-blog-post\">And a function to read the metadata for a single blog post.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getPostMetadata<\/span>(<span class=\"hljs-params\">slug: string, fileContents: string<\/span>): <span class=\"hljs-title\">PostMetadata<\/span> <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> { data } = matter(fileContents);\n\n  <span class=\"hljs-keyword\">const<\/span> result: PostMetadata = {\n    slug,\n  } <span class=\"hljs-keyword\">as<\/span> PostMetadata;\n\n  <span class=\"hljs-comment\">\/\/ Ensure only the minimal needed data is exposed<\/span>\n  metadataFields.forEach(<span class=\"hljs-function\"><span class=\"hljs-params\">field<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">typeof<\/span> data&#91;field] !== <span class=\"hljs-string\">\"undefined\"<\/span>) {\n      result&#91;field] = data&#91;field];\n    }\n  });\n\n  <span class=\"hljs-keyword\">return<\/span> result;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\" id=\"building-our-homepage\">Building the Homepage<\/h2>\n\n\n\n<p>Let\u2019s build the main page for our blog.&nbsp;<\/p>\n\n\n\n<p>Here&#8217;s the route for our root index (\/) path. We&#8217;ve defined a loader, as well as specified the component we want rendered. The loader will\u00a0read the titles and metadata for each post. Then our component will render links for each.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> Route = createFileRoute(<span class=\"hljs-string\">\"\/\"<\/span>)({\n  <span class=\"hljs-attr\">loader<\/span>: <span class=\"hljs-keyword\">async<\/span> () =&gt; {\n    <span class=\"hljs-keyword\">const<\/span> posts = <span class=\"hljs-keyword\">await<\/span> getAllPosts();\n    <span class=\"hljs-keyword\">return<\/span> {\n      posts,\n    };\n  },\n  <span class=\"hljs-attr\">component<\/span>: App,\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Don&#8217;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.<\/p>\n\n\n\n<p>Our loader reads our posts. Then we connect up a React component for this route. Let&#8217;s look at each, in turn. <\/p>\n\n\n\n<p>If you&#8217;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&#8217;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&#8217;s browser. That means there&#8217;s no way we can run Node APIs to read file contents.<\/p>\n\n\n\n<p>The solution is to use a Server Function. The docs\u00a0<a href=\"https:\/\/tanstack.com\/start\/latest\/docs\/framework\/react\/guide\/server-functions\">are here<\/a>, 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.<\/p>\n\n\n\n<p>This call below is a Server Function:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">const<\/span> posts = <span class=\"hljs-keyword\">await<\/span> getAllPosts();<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Let&#8217;s look at its definition.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">const<\/span> getAllPosts = createServerFn().handler(<span class=\"hljs-keyword\">async<\/span> () =&gt; {\n  <span class=\"hljs-keyword\">const<\/span> postContentLookup = getAllBlogPosts();\n\n  <span class=\"hljs-keyword\">const<\/span> blogPosts = <span class=\"hljs-built_in\">Object<\/span>.entries(postContentLookup).map(<span class=\"hljs-function\">(<span class=\"hljs-params\">&#91;<span class=\"hljs-params\">slug<\/span>, <span class=\"hljs-params\">content<\/span>]<\/span>) =&gt;<\/span> getPostMetadata(slug, content));\n\n  <span class=\"hljs-keyword\">const<\/span> allPosts: PostMetadata&#91;] = blogPosts\n    <span class=\"hljs-comment\">\/\/ sort posts by date in descending order<\/span>\n    .sort(<span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">post1<\/span>, <span class=\"hljs-params\">post2<\/span><\/span>) =&gt;<\/span> (post1.date &gt; post2.date ? <span class=\"hljs-number\">-1<\/span> : <span class=\"hljs-number\">1<\/span>));\n  <span class=\"hljs-keyword\">return<\/span> allPosts;\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>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&#8217;s loader runs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"rendering-the-blog-links\">Rendering the Blog Links<\/h3>\n\n\n\n<p>We won&#8217;t show the entire route component, but we will read the data from the loader.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">App<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> { posts } = Route.useLoaderData();<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Later, we&#8217;ll loop it and emit links for each blog post.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\">&lt;div&gt;\n  {posts.map(<span class=\"hljs-function\"><span class=\"hljs-params\">post<\/span> =&gt;<\/span> (\n    &lt;div key={post.title} className=<span class=\"hljs-string\">\"blog-list-item\"<\/span>&gt;\n      &lt;h1&gt;\n        &lt;Link to={<span class=\"hljs-string\">`\/blog\/$slug`<\/span>} params={{ slug: post.slug }}&gt;\n          {post.title}\n        &lt;<span class=\"hljs-regexp\">\/Link&gt;\n      &lt;\/<\/span>h1&gt;\n      &lt;small&gt;\n        &lt;DateFormatter dateString={post.date}&gt;&lt;<span class=\"hljs-regexp\">\/DateFormatter&gt;\n      &lt;\/<\/span>small&gt;\n      &lt;p&gt;{post.description}&lt;<span class=\"hljs-regexp\">\/p&gt;\n    &lt;\/<\/span>div&gt;\n  ))}\n&lt;<span class=\"hljs-regexp\">\/div&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We can see it works! <\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"948\" height=\"954\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img2.png?resize=948%2C954&#038;ssl=1\" alt=\"\" class=\"wp-image-9339\" style=\"aspect-ratio:0.9937153419593345;width:506px;height:auto\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img2.png?w=948&amp;ssl=1 948w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img2.png?resize=298%2C300&amp;ssl=1 298w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img2.png?resize=150%2C150&amp;ssl=1 150w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img2.png?resize=768%2C773&amp;ssl=1 768w\" sizes=\"auto, (max-width: 948px) 100vw, 948px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"rendering-each-blog-post\">Rendering Each Blog Post<\/h2>\n\n\n\n<p>Let&#8217;s get a route created to render an individual blog post. As the&nbsp;<code>&lt;Link&gt;<\/code>&nbsp;component from the homepage implied, we want our URLs of the form&nbsp;<code>\/blog\/title-of-post<\/code>. Obviously&nbsp;<code>title-of-post<\/code>&nbsp;is dynamic, so we&#8217;ll need a route variable. In TanStack, we do this by first creating a folder called&nbsp;<code>blog<\/code>&nbsp;inside of our&nbsp;<code>routes<\/code>&nbsp;folder, and then inside of that, we create a&nbsp;<code>$slug.tsx<\/code>&nbsp;file.<\/p>\n\n\n\n<p>The dollar sign in front of&nbsp;<code>$slug<\/code>&nbsp;means that&nbsp;$slug is actually a route parameter, which will be replaced with whatever is in the URL at that location.<\/p>\n\n\n\n<p>As before, the TanStack file watcher will scaffold a minimal route for us.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">import<\/span> { createFileRoute } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"@tanstack\/react-router\"<\/span>;\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> Route = createFileRoute(<span class=\"hljs-string\">\"\/blog\/$slug\"<\/span>)({\n  component: RouteComponent,\n});\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">RouteComponent<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> &lt;div&gt;Hello <span class=\"hljs-string\">\"\/junk\/$slug\"<\/span>!&lt;<span class=\"hljs-regexp\">\/div&gt;;\n}<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Let&#8217;s fill it out with some details.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> Route = createFileRoute(<span class=\"hljs-string\">\"\/blog\/$slug\"<\/span>)({\n  loader: <span class=\"hljs-keyword\">async<\/span> ({ params }) =&gt; {\n    <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-keyword\">await<\/span> getPostContent({ data: { slug: params.slug } });\n  },\n  head: <span class=\"hljs-function\">(<span class=\"hljs-params\">{ <span class=\"hljs-params\">params<\/span> }<\/span>) =&gt;<\/span> {\n    <span class=\"hljs-keyword\">return<\/span> {\n      meta: &#91;\n        {\n          title: <span class=\"hljs-string\">`<span class=\"hljs-subst\">${params.slug}<\/span> | Adam Rackis's blog`<\/span>,\n        },\n      ],\n    };\n  },\n  component: RouteComponent,\n});<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We add a loader that again calls a Server Function we&#8217;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&nbsp;<code>$slug<\/code>. This was because our route was named&nbsp;<code>$slug.tsx<\/code>.<\/p>\n\n\n\n<p>Then we have a&nbsp;<code>head<\/code>&nbsp;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.<\/p>\n\n\n\n<p>Now let&#8217;s look at our server function that grabs our post&#8217;s content, for whatever slug we have in our url.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> getPostContent = createServerFn()\n  .inputValidator(<span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">data<\/span>: { <span class=\"hljs-params\">slug<\/span>: <span class=\"hljs-params\">string<\/span> }<\/span>) =&gt;<\/span> data)\n  .handler(<span class=\"hljs-keyword\">async<\/span> ({ data }) =&gt; {\n    <span class=\"hljs-keyword\">const<\/span> postContentLookup = getAllBlogPosts();\n\n    <span class=\"hljs-keyword\">if<\/span> (!postContentLookup&#91;data.slug]) {\n      <span class=\"hljs-keyword\">throw<\/span> <span class=\"hljs-keyword\">new<\/span> <span class=\"hljs-built_in\">Error<\/span>(<span class=\"hljs-string\">`Post not found: <span class=\"hljs-subst\">${data.slug}<\/span>`<\/span>);\n    }\n\n    <span class=\"hljs-keyword\">const<\/span> post = <span class=\"hljs-keyword\">await<\/span> getPost(data.slug, postContentLookup&#91;data.slug]);\n\n    <span class=\"hljs-keyword\">return<\/span> { post };\n  });<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We get the entire list of all blog posts, and verify that the one we want is in there. If it is, we call&nbsp;<code>getPost<\/code>&nbsp;and return the result. Let&#8217;s take a look at&nbsp;<code>getPost<\/code>&nbsp;now<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-16\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getPost<\/span>(<span class=\"hljs-params\">slug: <span class=\"hljs-built_in\">string<\/span>, fileContents: <span class=\"hljs-built_in\">string<\/span><\/span>): <span class=\"hljs-title\">Promise<\/span>&lt;<span class=\"hljs-title\">Post<\/span>&gt; <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> { data, content: markdownContent } = matter(fileContents);\n  <span class=\"hljs-keyword\">const<\/span> content = <span class=\"hljs-keyword\">await<\/span> markdownToHtml(markdownContent);\n\n  <span class=\"hljs-keyword\">const<\/span> result: Post = {\n    slug,\n    content,\n  } <span class=\"hljs-keyword\">as<\/span> Post;\n\n  <span class=\"hljs-comment\">\/\/ Ensure only the minimal needed data is exposed<\/span>\n  postFields.forEach(<span class=\"hljs-function\"><span class=\"hljs-params\">field<\/span> =&gt;<\/span> {\n    <span class=\"hljs-keyword\">if<\/span> (<span class=\"hljs-keyword\">typeof<\/span> data&#91;field] !== <span class=\"hljs-string\">\"undefined\"<\/span>) {\n      result&#91;field] = data&#91;field];\n    }\n  });\n\n  <span class=\"hljs-keyword\">return<\/span> result;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-16\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The real work happens in&nbsp;<code>markdownToHtml<\/code>&nbsp;which converts our Markdown to HTML.<\/p>\n\n\n\n<p>I decided to use <a href=\"https:\/\/github.com\/markdown-it\/markdown-it\">markdown-it<\/a> along with <a href=\"https:\/\/shiki.matsu.io\/\">Shiki<\/a>, but there are endless options out there. Here&#8217;s what it looks like.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-17\" data-shcb-language-name=\"TypeScript\" data-shcb-language-slug=\"typescript\"><span><code class=\"hljs language-typescript\"><span class=\"hljs-keyword\">import<\/span> Shiki <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"@shikijs\/markdown-it\"<\/span>;\n<span class=\"hljs-keyword\">import<\/span> MarkdownIt <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"markdown-it\"<\/span>;\n\n<span class=\"hljs-keyword\">const<\/span> markdownIt = MarkdownIt({\n  html: <span class=\"hljs-literal\">true<\/span>,\n}).use(\n  <span class=\"hljs-keyword\">await<\/span> Shiki({\n    themes: {\n      light: <span class=\"hljs-string\">\"dark-plus\"<\/span>,\n      dark: <span class=\"hljs-string\">\"dark-plus\"<\/span>,\n    },\n    transformers: &#91;\n      {\n        name: <span class=\"hljs-string\">\"line-numbers-pre\"<\/span>,\n        preprocess: <span class=\"hljs-function\">(<span class=\"hljs-params\">_: <span class=\"hljs-params\">string<\/span>, <span class=\"hljs-params\">options<\/span>: <span class=\"hljs-params\">any<\/span><\/span>) =&gt;<\/span> {\n          <span class=\"hljs-keyword\">if<\/span> (options?.meta?.__raw?.includes(<span class=\"hljs-string\">\"line-numbers\"<\/span>)) {\n            options.attributes = {};\n            options.attributes.lineNumbers = <span class=\"hljs-literal\">true<\/span>;\n          }\n        },\n      },\n      {\n        name: <span class=\"hljs-string\">\"line-numbers-post\"<\/span>,\n        postprocess: <span class=\"hljs-function\">(<span class=\"hljs-params\"><span class=\"hljs-params\">html<\/span>, <span class=\"hljs-params\">options<\/span>: <span class=\"hljs-params\">any<\/span><\/span>) =&gt;<\/span> {\n          <span class=\"hljs-keyword\">if<\/span> (options?.attributes?.lineNumbers) {\n            <span class=\"hljs-keyword\">return<\/span> html.replace(<span class=\"hljs-regexp\">\/&lt;pre \/g<\/span>, <span class=\"hljs-string\">\"&lt;pre data-linenumbers \"<\/span>);\n          }\n          <span class=\"hljs-keyword\">return<\/span> html;\n        },\n      },\n    ],\n  }),\n);\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> <span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">markdownToHtml<\/span>(<span class=\"hljs-params\">markdown: <span class=\"hljs-built_in\">string<\/span><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> markdownIt.render(markdown);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-17\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">TypeScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">typescript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The vast majority of this setup was for a custom transformer that allows these special keywords after the triple backticks in Markdown.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-18\" data-shcb-language-name=\"Markdown\" data-shcb-language-slug=\"markdown\"><span><code class=\"hljs language-markdown\"><span class=\"hljs-code\">```<\/span>sql line-numbers\nSELECT id, SUM(amount)\nFROM some_table st\nJOIN other_table ot\nON st.id = ot.id\nWHERE active = true\nGROUP BY ot.id\n<span class=\"hljs-code\">```<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-18\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Markdown<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">markdown<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This then adds a&nbsp;<code>data-linenumbers<\/code>&nbsp;attribute to the pre element<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-19\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">pre<\/span> <span class=\"hljs-attr\">data-linenumbers<\/span>=<span class=\"hljs-string\">\"\"<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-19\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>And this attribute allows me to render line numbers via a CSS counter.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-20\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">pre<\/span><span class=\"hljs-selector-attr\">&#91;data-linenumbers]<\/span> <span class=\"hljs-selector-tag\">code<\/span> {\n  <span class=\"hljs-attribute\">counter-reset<\/span>: step;\n  <span class=\"hljs-attribute\">counter-increment<\/span>: step <span class=\"hljs-number\">0<\/span>;\n}\n\n<span class=\"hljs-selector-tag\">pre<\/span><span class=\"hljs-selector-attr\">&#91;data-linenumbers]<\/span> <span class=\"hljs-selector-tag\">code<\/span> <span class=\"hljs-selector-class\">.line<\/span><span class=\"hljs-selector-pseudo\">::before<\/span> {\n  <span class=\"hljs-attribute\">content<\/span>: <span class=\"hljs-built_in\">counter<\/span>(step);\n  <span class=\"hljs-attribute\">counter-increment<\/span>: step;\n  <span class=\"hljs-attribute\">width<\/span>: <span class=\"hljs-number\">1rem<\/span>;\n  <span class=\"hljs-attribute\">margin-right<\/span>: <span class=\"hljs-number\">1rem<\/span>;\n  <span class=\"hljs-attribute\">display<\/span>: inline-block;\n  <span class=\"hljs-attribute\">text-align<\/span>: right;\n  <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-built_in\">rgba<\/span>(<span class=\"hljs-number\">115<\/span>, <span class=\"hljs-number\">138<\/span>, <span class=\"hljs-number\">148<\/span>, <span class=\"hljs-number\">0.4<\/span>);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-20\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>I previously <a href=\"https:\/\/frontendmasters.com\/blog\/css-counters-in-action\/\">blogged about CSS counters<\/a>, and of course, the complete code for this sample blog <a href=\"https:\/\/github.com\/arackaf\/tanstack-blog-blog-post\">is on GitHub<\/a>.<\/p>\n\n\n\n<p>Now we can see our post.<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"987\" height=\"1024\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img3.png?resize=987%2C1024&#038;ssl=1\" alt=\"\" class=\"wp-image-9341\" style=\"aspect-ratio:0.9638828109459805;object-fit:cover;width:420px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img3.png?resize=987%2C1024&amp;ssl=1 987w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img3.png?resize=289%2C300&amp;ssl=1 289w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img3.png?resize=768%2C797&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img3.png?w=1446&amp;ssl=1 1446w\" sizes=\"auto, (max-width: 987px) 100vw, 987px\" \/><\/figure>\n<\/div>\n\n\n<p>And our line numbers work:<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large is-resized\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"349\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img4.png?resize=1024%2C349&#038;ssl=1\" alt=\"\" class=\"wp-image-9342\" style=\"aspect-ratio:2.934256055363322;object-fit:cover;width:420px\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img4.png?resize=1024%2C349&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img4.png?resize=300%2C102&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img4.png?resize=768%2C262&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/img4.png?w=1424&amp;ssl=1 1424w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n<\/div>\n\n\n<h2 class=\"wp-block-heading\" id=\"on-to-part-2\">On to Part 2<\/h2>\n\n\n\n<p id=\"reading-metadata-about-each-blog-post\">Our blog is set up and working. In Part 2 (coming soon!), we&#8217;ll look at some simple tricks for deploying our blog as a static site with no server dependencies.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A site building framework like TanStack Start can be used to make a server-side rendered blog, no problemo. <\/p>\n","protected":false},"author":21,"featured_media":9345,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[208,240],"class_list":["post-9303","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-blogging","tag-tanstack"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/tanstack-blog-1.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9303","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/21"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=9303"}],"version-history":[{"count":23,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9303\/revisions"}],"predecessor-version":[{"id":9364,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9303\/revisions\/9364"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/9345"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=9303"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=9303"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=9303"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}