TanStack Start has become my go-to for full stack React apps. One of the things I enjoy most about it is how cleanly it handles three things that are usually spread across multiple files and libraries — metadata, data fetching, and loading UI — all colocated in a single route.
Here's how I use head, loader, and pendingComponent together.
head — Per-Route Metadata
head lets you define document-level metadata per route. Page title, meta description, open graph tags — all scoped to that route without a global workaround or a third party head manager.
head: () => ({
meta: [
{ title: 'All Articles — Big Cats' },
{ name: 'description', content: 'Every story, every species.' },
],
}),
Each route owns its own SEO info. No context providers, no helmet libraries, no prop drilling.
loader — Data Fetching Before Render
loader is where you fetch everything your route needs. It runs on the server or client depending on your SSR config, and the data it returns is available in your component via useLoaderData. Parallel fetches with Promise.all are the standard pattern here.
loader: async () => {
const [posts, categories] = await Promise.all([
getPosts(),
getCategories()
])
return { posts, categories }
},
Both fetches run in parallel — no waterfall, no useEffect, no loading state management in the component. The data is just there when the component renders.
pendingComponent — Skeleton While the Loader Resolves
pendingComponent renders while the loader is still resolving. It's the right place for skeletons or placeholder UI. The pendingMs and pendingMinMs options give you timing control — useful for avoiding a skeleton flash on fast connections while still showing it on slow ones.
pendingComponent: () => (
<main className="min-h-screen bg-black text-white">
<section className="border-b border-white/10 px-6 py-16 text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">
All Articles
</h1>
<p className="mt-3 text-white/50">Every story, every species.</p>
</section>
<section className="mx-auto max-w-6xl px-6 py-16">
<PostGridSkeleton />
</section>
</main>
),
The layout and headings render immediately while PostGridSkeleton holds the place for the actual content. The page feels instant even on slower connections.
All Together in One Route
What I appreciate most is that all three live in the same route file. The metadata, the data fetching, and the loading UI are colocated — no jumping between files to understand what a route does.
export const Route = createFileRoute('/articles')({
head: () => ({
meta: [
{ title: 'All Articles — Big Cats' },
{ name: 'description', content: 'Every story, every species.' },
],
}),
loader: async () => {
const [posts, categories] = await Promise.all([
getPosts(),
getCategories()
])
return { posts, categories }
},
pendingComponent: () => (
<main className="min-h-screen bg-black text-white">
<section className="border-b border-white/10 px-6 py-16 text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">
All Articles
</h1>
<p className="mt-3 text-white/50">Every story, every species.</p>
</section>
<section className="mx-auto max-w-6xl px-6 py-16">
<PostGridSkeleton />
</section>
</main>
),
})
If you're evaluating TanStack Start for your next full stack project, this colocation pattern alone is worth the look. Everything your route needs — SEO, data, loading UI — in one place.
I pair this with Directus as my CMS and Tailwind CSS v4 for styling. The getPosts() and getCategories() calls come from a single directus.ts file using the Directus SDK — I covered that setup in a previous post if you want to see how that layer is structured.
Top comments (0)