TanStack Start brings type-safe routing to React. Now add a type-safe blog in minutes — complete with database, API, SSR, and SEO — using Better Stack's modular plugin system.

TanStack Start represents everything right about modern React development. Type-safe routing. First-class SSR. File-based conventions that don't fight you. It's the framework for developers who care about architecture.
So when it's time to add a blog, you expect the same quality. Type safety end-to-end. Server-side rendering that just works. Components that respect your patterns.
What you get instead? The same tired options as every other framework. Wire up a CMS. Build from scratch. Spend days on a problem that should be solved.
TanStack Start deserves better.
TanStack Router pioneered type-safe routing in React. But type safety shouldn't stop at navigation. Your features — auth, blogs, dashboards — should be equally typed, equally integrated, equally first-class.
This is the promise of modular full-stack features. Not scaffolding. Not templates. Production-ready functionality that composes into your app with full type inference.
Better Stack delivers this for TanStack Start. A blog plugin that provides typed database schemas, API routes, React components, SSR loaders, and SEO metadata — all designed to work with TanStack's patterns.
Let's build it.
Your TanStack Start project needs:
<Toaster /> for notificationsAdd the core package and a database adapter. We'll use Prisma in this guide, but Better Stack supports multiple adapters:
@btst/adapter-prisma — For Prisma ORM (PostgreSQL, MySQL, SQLite, CockroachDB)@btst/adapter-drizzle — For Drizzle ORM@btst/adapter-kysely — For Kysely query builder@btst/adapter-mongodb — For MongoDB@btst/adapter-memory — For development and testingnpm install @btst/stack @btst/adapter-prisma
Set up lib/better-stack.ts:
import { betterStack } from "@btst/stack"
import { blogBackendPlugin } from "@btst/stack/plugins/blog/api"
import { createPrismaAdapter } from "@btst/adapter-prisma"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
const { handler, dbSchema } = betterStack({
basePath: "/api/data",
plugins: {
blog: blogBackendPlugin()
},
adapter: (db) => createPrismaAdapter(prisma, db, {
provider: "postgresql"
})
})
export { handler, dbSchema }
This gives you a complete API layer — typed handlers, database operations, request validation — from a single configuration.
TanStack Start uses file-based server handlers. Create a catch-all route:
import { createFileRoute } from '@tanstack/react-router'
import { handler } from '@/lib/better-stack'
export const Route = createFileRoute('/api/data/$')({
server: {
handlers: {
GET: async ({ request }) => handler(request),
POST: async ({ request }) => handler(request),
PUT: async ({ request }) => handler(request),
DELETE: async ({ request }) => handler(request),
},
},
})
Clean. Type-safe. Your blog API is live.
Generate Prisma schema additions:
npx @btst/cli generate --config=lib/better-stack.ts --orm=prisma --output=prisma/schema.prisma
Run the migration:
npx prisma migrate dev --name add-blog
Create lib/better-stack-client.tsx:
import { createStackClient } from "@btst/stack/client"
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
import { QueryClient } from "@tanstack/react-query"
const getBaseURL = () =>
typeof window !== 'undefined'
? (import.meta.env.VITE_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:3000")
export const getStackClient = (queryClient: QueryClient) => {
const baseURL = getBaseURL()
return createStackClient({
plugins: {
blog: blogClientPlugin({
apiBaseURL: baseURL,
apiBasePath: "/api/data",
siteBaseURL: baseURL,
siteBasePath: "/pages",
queryClient: queryClient,
seo: {
siteName: "My Blog",
author: "Your Name",
twitterHandle: "@yourhandle",
locale: "en_US",
defaultImage: `${baseURL}/og-image.png`,
},
})
}
})
}
Create a query client utility that works with TanStack Start's SSR:
import { QueryClient, isServer } from "@tanstack/react-query"
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: isServer ? 60 * 1000 : 0,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false
},
dehydrate: {
shouldDehydrateQuery: () => true
}
}
})
}
let browserQueryClient: QueryClient | undefined = undefined
export function getOrCreateQueryClient() {
if (isServer) {
return makeQueryClient()
}
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
Configure your router with the query client:
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { getOrCreateQueryClient } from '@/lib/query-client'
export interface MyRouterContext {
queryClient: QueryClient
}
export function getRouter() {
const queryClient = getOrCreateQueryClient()
const router = createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: false,
context: {
queryClient,
},
notFoundMode: "root",
})
setupRouterSsrQueryIntegration({
router,
queryClient,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
Add the blog CSS:
@import "@btst/stack/plugins/blog/css";
Set up TanStack-specific overrides:
import { BetterStackProvider } from "@btst/stack/context"
import { QueryClientProvider } from "@tanstack/react-query"
import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router"
const getBaseURL = () =>
typeof window !== 'undefined'
? (import.meta.env.VITE_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:3000")
type PluginOverrides = {
blog: BlogPluginOverrides
}
export const Route = createFileRoute('/pages')({
component: Layout
})
function Layout() {
const router = useRouter()
const context = Route.useRouteContext()
const baseURL = getBaseURL()
return (
<QueryClientProvider client={context.queryClient}>
<BetterStackProvider<PluginOverrides>
basePath="/pages"
overrides={{
blog: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (href) => router.navigate({ to: href }),
uploadImage: async (file) => {
// Implement your image upload logic
return "https://example.com/uploads/image.jpg"
},
Link: ({ href, children, className, ...props }) => (
<Link to={href} className={className} {...props}>
{children}
</Link>
),
}
}}
>
<Outlet />
</BetterStackProvider>
</QueryClientProvider>
)
}
TanStack Start's loader pattern integrates perfectly with Better Stack:
import { createFileRoute, notFound } from "@tanstack/react-router"
import { getStackClient } from "@/lib/better-stack-client"
import { normalizePath } from "@btst/stack/client"
export const Route = createFileRoute("/pages/$")({
ssr: true,
component: Page,
loader: async ({ params, context }) => {
const routePath = normalizePath(params._splat)
const stackClient = getStackClient(context.queryClient)
const route = stackClient.router.getRoute(routePath)
if (!route) throw notFound()
if (route?.loader) await route.loader()
return { meta: await route?.meta?.() }
},
head: ({ loaderData }) => {
return loaderData?.meta && Array.isArray(loaderData.meta)
? { meta: loaderData.meta }
: { meta: [{ title: "Blog" }] }
},
notFoundComponent: () => <p>This page doesn't exist!</p>
})
function Page() {
const context = Route.useRouteContext()
const { _splat } = Route.useParams()
const routePath = normalizePath(_splat)
const route = getStackClient(context.queryClient).router.getRoute(routePath)
return route && route.PageComponent ? <route.PageComponent /> : <div>Route not found</div>
}
Notice how naturally this fits TanStack Start's patterns:
loader handles server-side data prefetchinghead generates SEO metadatassr: true enables server renderingNavigate to /pages/blog. Here's what you have:
Pages:
/pages/blog — Blog homepage with published posts/pages/blog/drafts — Draft management/pages/blog/new — Rich markdown editor/pages/blog/:slug — Individual post pages/pages/blog/:slug/edit — Edit existing posts/pages/blog/tag/:tagSlug — Tag-filtered viewsFeatures:
head functionAPI:
GET /api/data/posts — List with filteringPOST /api/data/posts — CreatePUT /api/data/posts/:id — UpdateDELETE /api/data/posts/:id — DeleteGET /api/data/tags — List tagsGenerate a sitemap from all plugins:
import { createFileRoute } from "@tanstack/react-router"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "@/lib/better-stack-client"
import { sitemapEntryToXmlString } from "@btst/stack/client"
export const Route = createFileRoute("/sitemap.xml")({
server: {
handlers: {
GET: async () => {
const queryClient = new QueryClient()
const stackClient = getStackClient(queryClient)
const entries = await stackClient.generateSitemap()
const xml = sitemapEntryToXmlString(entries)
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=0, s-maxage=3600",
},
})
},
},
},
})
Protect your blog with typed hooks:
import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api"
const blogHooks: BlogBackendHooks = {
onBeforeListPosts(filter, context) {
if (filter.published === false) {
return isAdmin(context.headers as Headers)
}
return true
},
onBeforeCreatePost(data, context) {
return isAdmin(context.headers as Headers)
},
onBeforeUpdatePost(postId, data, context) {
return isAdmin(context.headers as Headers)
},
onBeforeDeletePost(postId, context) {
return isAdmin(context.headers as Headers)
},
}
const { handler, dbSchema } = betterStack({
plugins: {
blog: blogBackendPlugin(blogHooks)
},
// ...
})
TanStack Start is built on principles: type safety, composition, framework-agnostic design. Better Stack shares these values.
The blog plugin isn't a black box. It's typed end-to-end. The database schema is inspectable. The API routes are overridable. The React components use your design tokens. Everything flows through React Query, which TanStack Start already integrates natively.
This is what modular full-stack development looks like when both the framework and the feature library share the same philosophy. No impedance mismatch. No fighting abstractions. Just composition.
TanStack brought type-safe routing to React. Better Stack brings type-safe features. Together, they represent a future where building production apps means assembling well-designed modules — not rewriting infrastructure.
Build with better blocks.
Resources:
Powered by Better-Stack