
Tony Spiro
April 18, 2026
Headless CMS for TanStack Router: Type-Safe Content with Cosmic
You're building a React app with TanStack Router. You've got file-based routing, fully type-safe Link components, and route loaders that eliminate waterfall fetches. The routing problem is solved. Now you need content.
You could hard-code it. You could drop markdown files into your repo. Or you could wire up a headless CMS that gives your whole team a clean editing interface while keeping your frontend completely decoupled.
That's exactly what this guide covers: how to use Cosmic as the content backend for a TanStack Router application. You'll get:
- Content fetched in route
loaderfunctions (no waterfall, no flash of empty content) - Full TypeScript types from the Cosmic JavaScript SDK
- Nested dynamic routes for blog posts, product pages, or any content with a slug
- A working pattern you can drop into any existing TanStack Router project
Want to follow along with a live bucket? Create a free Cosmic account in about 60 seconds. The free plan includes 1 Bucket, 2 team members, and 1,000 objects, and no credit card is required. Every code sample below runs against it as-is.
Why TanStack Router + Cosmic is a great pairing
TanStack Router is the most widely adopted piece of the TanStack suite. Millions of React developers use it standalone, outside of TanStack Start. Its defining feature is type safety end-to-end: typed params, typed search params, typed loaders, and typed Link components that catch broken routes at compile time.
Cosmic is a headless CMS with a JavaScript/TypeScript SDK that returns typed content objects. When you combine TanStack Router's typed loaders with Cosmic's typed SDK responses, you get a content pipeline where TypeScript catches schema mismatches before they hit production.
Neither tool locks you in. TanStack Router works with Vite, Webpack, Rspack, or any build tool. Cosmic serves content over a REST API to any frontend, any framework, any deployment target. If you are evaluating the broader picture first, see our React CMS overview and the headless CMS comparison guide.
Prerequisites
- A Cosmic account (free tier available)
- A TanStack Router project (we'll scaffold one below)
- Node.js 18+
Step 1: Scaffold a TanStack Router project
The fastest way to start is with the official Vite + TanStack Router template:
npx create-tsrouter-app@latest my-cosmic-app --template react-file-based cd my-cosmic-app npm install
This gives you file-based routing out of the box. Routes are defined by files inside src/routes/, and TanStack Router generates the route tree automatically.
Step 2: Install the Cosmic SDK
npm install @cosmicjs/sdk
Create a file at src/lib/cosmic.ts to initialize the client:
// src/lib/cosmic.ts import { createBucketClient } from '@cosmicjs/sdk' export const cosmic = createBucketClient({ bucketSlug: import.meta.env.VITE_COSMIC_BUCKET_SLUG as string, readKey: import.meta.env.VITE_COSMIC_READ_KEY as string, })
Add your credentials to .env.local:
VITE_COSMIC_BUCKET_SLUG=your-bucket-slug VITE_COSMIC_READ_KEY=your-read-key
You can find both values in your Cosmic dashboard under Bucket > Settings > API Keys.
Step 3: Create your content model in Cosmic
In your Cosmic dashboard, create an Object Type called Posts with the following metafields:
| Field | Type | Key |
|---|---|---|
| Cover Image | File | cover_image |
| Excerpt | Textarea | excerpt |
| Body | Markdown | body |
| Published Date | Date | published_date |
Add a few test posts with titles, slugs, and body content. You're ready to fetch.
Step 4: Define TypeScript types for your content
Create src/types/cosmic.ts:
// src/types/cosmic.ts export interface CosmicPost { id: string title: string slug: string metadata: { excerpt: string body: string published_date: string cover_image?: { imgix_url: string } } } export interface CosmicPostList { objects: CosmicPost[] total: number }
These types will flow through your route loaders, giving you autocomplete and error checking on every field.
Step 5: Fetch content in a route loader
This is where TanStack Router really shines. The loader function runs before the component renders, so your content is ready the moment the route activates, with no loading spinners on initial render.
Create the blog index route at src/routes/blog.index.tsx:
// src/routes/blog.index.tsx import { createFileRoute, Link } from '@tanstack/react-router' import { cosmic } from '../lib/cosmic' import type { CosmicPost } from '../types/cosmic' export const Route = createFileRoute('/blog/')({ loader: async () => { const data = await cosmic.objects .find({ type: 'posts' }) .props('id,title,slug,metadata.excerpt,metadata.published_date,metadata.cover_image') .limit(20) .sort('-metadata.published_date') return data.objects as CosmicPost[] }, component: BlogIndex, }) function BlogIndex() { const posts = Route.useLoaderData() return ( <div className="max-w-3xl mx-auto py-12 px-4"> <h1 className="text-4xl font-bold mb-8">Blog</h1> <ul className="space-y-8"> {posts.map((post) => ( <li key={post.id}> <Link to="/blog/$slug" params={{ slug: post.slug }}> <h2 className="text-2xl font-semibold hover:underline">{post.title}</h2> </Link> <p className="text-gray-500 text-sm mt-1">{post.metadata.published_date}</p> <p className="mt-2 text-gray-700">{post.metadata.excerpt}</p> </li> ))} </ul> </div> ) }
Notice the Link component: TanStack Router knows the shape of /blog/$slug at compile time. If you mistype the param name, you'll get a TypeScript error before you ever open a browser.
Step 6: Nested dynamic route for individual posts
Now create the dynamic post route at src/routes/blog.$slug.tsx:
// src/routes/blog.$slug.tsx import { createFileRoute, notFound } from '@tanstack/react-router' import { cosmic } from '../lib/cosmic' import type { CosmicPost } from '../types/cosmic' export const Route = createFileRoute('/blog/$slug')({ loader: async ({ params }) => { try { const data = await cosmic.objects .findOne({ type: 'posts', slug: params.slug, }) .props('id,title,slug,metadata') return data.object as CosmicPost } catch { throw notFound() } }, component: BlogPost, notFoundComponent: () => <p>Post not found.</p>, }) function BlogPost() { const post = Route.useLoaderData() const { metadata } = post return ( <article className="max-w-2xl mx-auto py-12 px-4"> {metadata.cover_image && ( <img src={`${metadata.cover_image.imgix_url}?w=1200&auto=format,compress`} alt={post.title} className="w-full rounded-lg mb-8" /> )} <h1 className="text-4xl font-bold mb-4">{post.title}</h1> <p className="text-gray-500 text-sm mb-8">{metadata.published_date}</p> <div className="prose prose-lg max-w-none" dangerouslySetInnerHTML={{ __html: metadata.body }} /> </article> ) }
The params.slug value is fully typed. TanStack Router infers it from the filename blog.$slug.tsx, so there is no manual type casting and no as string hacks.
The notFound() throw is also typed. TanStack Router catches it and renders the notFoundComponent you defined on the route, keeping your 404 handling clean and co-located with the route itself.
Step 7: Add route-level error boundaries
Cosmic API calls can fail. Wrap your routes with an errorComponent to handle it gracefully:
// Add to either route definition errorComponent: ({ error }) => ( <div className="max-w-2xl mx-auto py-12 px-4"> <h1 className="text-2xl font-bold text-red-600">Something went wrong</h1> <p className="mt-2 text-gray-600">{error.message}</p> </div> ),
TanStack Router calls errorComponent if the loader throws (and it isn't a notFound()). Your app stays running; only the affected route shows the error state.
Step 8: Preloading on hover for instant navigation
TanStack Router supports route preloading. It can fire the loader the moment a user hovers over a link, so the data is cached by the time they click.
Enable it globally in src/main.tsx:
import { RouterProvider, createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' const router = createRouter({ routeTree, defaultPreload: 'intent', // preload on hover/focus defaultPreloadStaleTime: 30_000, // cache for 30 seconds })
With defaultPreload: 'intent', hovering over a <Link to="/blog/$slug"> prefetches the Cosmic content for that post. Navigation feels instant, and it costs you zero additional code in your route files. It is a single router config option.
Step 9: Search params for filtering and pagination
TanStack Router's search params are typed and validated just like route params. Here's a pattern for paginated Cosmic content:
import { createFileRoute, Link } from '@tanstack/react-router' import { z } from 'zod' import { cosmic } from '../lib/cosmic' const searchSchema = z.object({ page: z.number().int().min(1).catch(1), limit: z.number().int().min(1).max(50).catch(10), }) export const Route = createFileRoute('/blog/')({ validateSearch: searchSchema, loader: async ({ context: _ctx, abortController: _ac, ...opts }) => { const { page, limit } = opts.deps as { page: number; limit: number } const skip = (page - 1) * limit const data = await cosmic.objects .find({ type: 'posts' }) .props('id,title,slug,metadata.excerpt,metadata.published_date') .limit(limit) .skip(skip) .sort('-metadata.published_date') return { posts: data.objects, total: data.total, page, limit } }, loaderDeps: ({ search }) => ({ page: search.page, limit: search.limit }), component: BlogIndex, })
The loaderDeps function tells TanStack Router to re-run the loader when page or limit changes. The search params are validated with Zod, so ?page=abc gracefully falls back to page: 1 instead of crashing.
Putting it all together: the file structure
src/ lib/ cosmic.ts # Cosmic SDK client routes/ __root.tsx # Root layout blog.index.tsx # /blog, list of posts blog.$slug.tsx # /blog/:slug, single post types/ cosmic.ts # Content type definitions main.tsx # Router setup with preloading
This is a minimal, production-ready structure. Each route file owns its data fetching, its loading state, and its error handling. No global state, no prop drilling, no context providers for content.
Performance considerations
Images via Imgix: Cosmic serves all media through Imgix. Append transform params to your image URLs for automatic optimization:
// Responsive, WebP-optimized image const src = `${post.metadata.cover_image.imgix_url}?w=800&auto=format,compress&fit=crop`
Stale-while-revalidate: Set defaultPreloadStaleTime to match your content update frequency. For a blog with infrequent updates, 60 seconds is reasonable. For real-time content, set it to 0.
Parallel loaders: TanStack Router runs sibling route loaders in parallel. If your layout route and page route both need Cosmic data, they fetch simultaneously with no code changes needed.
What to build next
This tutorial covers the core pattern: typed loaders, dynamic slugs, nested routes, and preloading. Here's where to take it from here:
- Add TanStack Start for SSR and server-side data fetching. The loaders you wrote here translate directly. Read Headless CMS for TanStack Start: Build a Blog with Cosmic for the full walkthrough.
- Add TanStack Query for client-side caching, background revalidation, and optimistic UI. TanStack Router integrates with TanStack Query via the External Data Loading guide.
- Add Cosmic localization for multi-language content. The SDK supports locale-scoped queries with one extra parameter.
- Deploy to Vercel or Netlify. Both support Vite apps with zero configuration.
Start building
Cosmic's free plan includes everything you need to get started: 1 Bucket, 2 team members, and 1,000 objects. No credit card required. Paid plans start at $49/month for Builder (2 Buckets, 3 team members, 5,000 objects), and you can see the full breakdown on the pricing page.
Sign up for Cosmic free and have your first route loader fetching real CMS content in under 10 minutes.
Want a personalized walkthrough of how Cosmic fits your stack? Book a demo with Tony.
Canonical URL: https://www.cosmicjs.com/blog/headless-cms-for-tanstack-router






