Content Collections for a Technical Blog That Actually Performs

How we use content-collections with TanStack Start to serve our engineering blog with zero client-side JavaScript for content.

By Anokuro Engineering··Tooling

This blog you are reading runs on TanStack Start with content-collections handling every piece of written content. The entire site ships zero client-side JavaScript for content rendering. Not "minimal JavaScript." Zero. The HTML arrives fully rendered, the CSS is inlined for above-the-fold content, and the blog post you are reading right now required exactly one network request to display.

We did not arrive here by accident. We tried four other approaches first and they were all worse.

Why Not MDX

MDX is the default answer for technical blogs in the React ecosystem. It is the wrong answer.

MDX compiles Markdown into React components. This means your content becomes JavaScript. It ships in your JavaScript bundle. It executes in the browser. A 2,000-word blog post with no interactive elements still requires the React runtime to render. On our benchmarks, a typical MDX-rendered blog post adds 47KB of gzipped JavaScript to the page and takes 120ms to hydrate on a mid-range mobile device.

Our blog posts have no interactive elements. They are text, code blocks, headings, and occasionally an image. Shipping a JavaScript runtime to render static text is architecturally wrong. It is not a performance optimization problem. It is a "you chose the wrong abstraction" problem.

Why Not Contentlayer

Contentlayer was the right idea at the wrong time. It compiled content at build time, generated TypeScript types, and integrated with Next.js. We used it briefly in late 2024. Then the maintainer stopped responding to issues. Dependencies fell behind. The Next.js integration broke with App Router changes. The project is effectively abandoned.

We will not build critical infrastructure on unmaintained software. The supply chain risk alone disqualifies it. When your content pipeline breaks at 11pm and the library's last commit was eight months ago, you are on your own.

Why Not a Headless CMS

We evaluated Sanity, Contentful, and Strapi. They solve a problem we do not have. Our content authors are engineers who write in Markdown, use Git for version control, and review each other's posts in pull requests. A CMS adds a runtime dependency (API calls at build time or worse, at request time), a monthly bill that scales with content volume, and a web-based editor that is worse than VS Code for writing technical content with code blocks.

The operational risk is also unacceptable. If Contentful has an outage during our build pipeline, we cannot deploy. We refuse to add external runtime dependencies for serving static content.

Why content-collections

content-collections does exactly what we need and nothing more. It reads Markdown files from a directory, validates frontmatter against a Zod schema, compiles content at build time, and exports typed collections that we import like any other module.

The key properties:

  1. Build-time only. Content is compiled during vite build. There is no runtime content fetching, no API calls, no client-side processing. The output is pre-rendered HTML strings that get embedded directly into our server-rendered pages.

  2. Zod validation. Every post's frontmatter is validated against a strict schema. If an engineer forgets the description field or uses an invalid date format, the build fails with a clear error message. Not a runtime error. Not a hydration mismatch. A build failure that blocks deployment.

  3. TypeScript types generated from the schema. When we access post.title in our TanStack Start route, TypeScript knows the type. When we add a new frontmatter field, we update the schema once and get type-checking everywhere. This is not a nice-to-have. With 30+ articles and growing, type safety on content is what prevents broken pages from reaching production.

  4. Zero runtime overhead. The compiled content is a static import. Vite tree-shakes unused posts. The HTML for each post is a string that gets inserted into the server response. No Markdown parser runs in the browser. No syntax highlighting library loads on the client.

Custom Transforms

content-collections supports custom transform functions that run at build time. We use three:

Reading time calculation. We compute reading time during the build using a word count with a 238 WPM average (the commonly cited adult reading speed for technical content). This number appears in the post metadata without any client-side computation. The transform strips code blocks from the word count because engineers scan code, they do not read it word-by-word. Our reading time estimates are consistently more accurate than the Medium-style calculation that counts code as prose.

Heading extraction for table of contents. We parse the compiled HTML to extract all h2 and h3 elements, generating a structured TOC array with id, text, and depth properties. The TOC component receives this array as a prop and renders a static navigation sidebar. No client-side DOM querying. No IntersectionObserver for scroll tracking — we considered it, but the JavaScript cost outweighs the UX benefit for a blog. The TOC is a flat list of anchor links. It works without JavaScript.

OG image generation at build time. Each post gets a programmatically generated Open Graph image using @vercel/og (the Satori-based image generator) during the build. The image includes the post title, description, reading time, and our brand elements, rendered to a 1200x630 PNG. This runs once per build, not per request. The generated images are hashed by content, so unchanged posts do not regenerate images. On a typical build with 30 posts, OG image generation adds 4.2 seconds. We cache aggressively — only new or modified posts trigger regeneration.

Integration With TanStack Start

Our blog routing uses TanStack Start's file-based routing with a dynamic segment:

src/routes/article.$slug.tsx

The route loader fetches the post by slug from the content-collections export. This is a synchronous import, not a network request — the content is already compiled into the server bundle. The loader returns the post data including the pre-rendered HTML, and the component renders it with dangerouslySetInnerHTML.

Yes, we use dangerouslySetInnerHTML. The HTML is generated at build time from Markdown we wrote ourselves and validated with Zod. There is no user-generated content. There is no XSS vector. The naming is a React convention designed to make you think twice. We thought twice. It is fine.

TanStack Start's type-safe routing means the $slug parameter is typed. The loader function validates that the slug corresponds to an existing post and returns a 404 if it does not. We get route-level type safety from the URL parameter through to the rendered component.

For navigation, we use TanStack Router's Link component with preload="intent". When a user hovers over a link to another post, the route data prefetches. Because the data is the pre-rendered HTML string (no API call, no database query), prefetching completes in under 5ms on our infrastructure. Navigation between posts feels instant because it is instant.

Handling 30+ articles at build time. Our build processes all posts in parallel using content-collections' built-in concurrency. The content compilation step (Markdown parsing, syntax highlighting with Shiki, heading extraction, reading time) for 34 posts takes 1.1 seconds. The total build time for the entire site (47 routes, 34 blog posts, OG image generation) is 1.8 seconds. We will address build performance in more detail in our Vite post.

Content Workflow

Our content workflow is deliberately simple:

  1. Write in VS Code. We use the Markdown All in One extension for preview and the Grammarly extension for catching obvious prose issues. No custom editor. No CMS dashboard.

  2. Open a pull request. Every post goes through code review. Engineers review each other's technical claims, benchmark methodology, and writing clarity. The PR triggers a preview build on Cloudflare Pages so reviewers can see the rendered post.

  3. Merge to main. The merge triggers a production build and deploy. Build time is under 2 seconds. Deploy propagation to Cloudflare's edge takes approximately 15 seconds. Total time from merge to live: under 20 seconds.

  4. No staging environment for content. The preview build on the PR is the staging environment. We do not maintain a separate staging deployment for the blog. The content is either in review (PR preview) or in production (main branch). Two states. Simple.

Performance Numbers

Lighthouse scores for a typical blog post page:

  • Performance: 100
  • First Contentful Paint: 0.4s
  • Largest Contentful Paint: 0.6s
  • Total Blocking Time: 0ms
  • Cumulative Layout Shift: 0

Total JavaScript shipped for a blog post page: 0 bytes for content rendering. The only JavaScript is the TanStack Router runtime for client-side navigation (18KB gzipped), which loads asynchronously and does not block content display.

Transfer size for a 1,500-word blog post: 14KB (HTML + inlined critical CSS). A user on a 3G connection sees the full rendered post in under 1 second.

The Principle

Content that does not change between page loads should not require JavaScript to render. This is not a progressive enhancement philosophy. It is an engineering constraint derived from our performance requirements. Every byte of JavaScript on a content page is a byte that exists because someone chose the wrong tool.

content-collections is the right tool. It compiles content at build time, validates it with types, and produces static HTML. There is nothing to optimize at runtime because there is nothing running at runtime. That is the correct architecture for a technical blog.

Copyright © 2026 Anokuro Pvt. Ltd. Singapore. All rights reserved.