Stop rebuilding blog infrastructure from scratch. Add a full-featured blog to your React Router v7 app with database, API, SEO, and editor — all in under 15 minutes.

React Router v7 changed how we think about data loading and server rendering. Loaders, actions, nested routes — it's an elegant model for building full-stack React apps.
But when it's time to add a blog? You're back to the same old choices.
Option A: Integrate a headless CMS. Configure webhooks. Handle caching. Map external schemas to your app's types. Hope the API doesn't change.
Option B: Build it yourself. Database models, API routes, markdown parsing, draft states, SEO tags, an editor UI. Days of work for a feature that feels like it should be solved.
Neither option respects your time.
The real problem isn't that blogs are hard. It's that we keep rebuilding the same solved problems. Auth systems, dashboards, content management — these features get reimplemented in every project, with the same patterns, the same bugs, the same technical debt.
What if blog functionality was a module? Not a template. Not a service. A typed, full-stack feature that drops into your React Router app and respects its conventions.
This is what Better Stack enables. A blog plugin that provides database schemas, API handlers, React components, SEO metadata, and SSR loaders — integrated with your existing stack, not bolted on top.
Let's build it.
Your React Router project needs:
<Toaster /> for notificationsInstall the core package with 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 @tanstack/react-query @btst/adapter-prisma
Set up lib/better-stack.ts with the blog plugin:
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 }
One configuration. Full CRUD API. Database schema. Type safety.
React Router uses file-based routing. Create a catch-all route for Better Stack:
import { handler } from "~/lib/better-stack"
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router"
export async function loader({ request }: LoaderFunctionArgs) {
return handler(request)
}
export async function action({ request }: ActionFunctionArgs) {
return handler(request)
}
Your blog API is live. Posts, tags, CRUD operations — all handled.
Run the CLI to generate Prisma schema additions:
npx @btst/cli generate --config=lib/better-stack.ts --orm=prisma --output=prisma/schema.prisma
Apply the migration:
npx prisma migrate dev --name add-blog
Create lib/better-stack-client.tsx for client-side routing and data fetching:
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:5173")
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:
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
}
Add the provider to your root:
import { QueryClientProvider } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "~/lib/query-client"
import { Outlet } from "react-router"
export default function App() {
const queryClient = getOrCreateQueryClient()
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
Add the blog CSS to your stylesheet:
@import "@btst/stack/plugins/blog/css";
Set up React Router-specific overrides:
import { Outlet, Link, useNavigate } from "react-router"
import { BetterStackProvider } from "@btst/stack/context"
import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
const getBaseURL = () =>
typeof window !== 'undefined'
? (import.meta.env.VITE_BASE_URL || window.location.origin)
: (process.env.BASE_URL || "http://localhost:5173")
type PluginOverrides = {
blog: BlogPluginOverrides
}
export default function Layout() {
const navigate = useNavigate()
const baseURL = getBaseURL()
return (
<BetterStackProvider<PluginOverrides>
basePath="/pages"
overrides={{
blog: {
apiBaseURL: baseURL,
apiBasePath: "/api/data",
navigate: (href) => navigate(href),
uploadImage: async (file) => {
// Implement your image upload logic
// e.g., upload to S3, Cloudinary, R2
return "https://example.com/uploads/image.jpg"
},
Link: ({ href, children, className, ...props }) => (
<Link to={href || ""} className={className} {...props}>
{children}
</Link>
),
}
}}
>
<Outlet />
</BetterStackProvider>
)
}
This route handles all blog pages with SSR data loading:
import type { Route } from "./+types/index"
import { useLoaderData } from "react-router"
import { dehydrate, HydrationBoundary, QueryClient, useQueryClient } from "@tanstack/react-query"
import { getStackClient } from "~/lib/better-stack-client"
import { normalizePath } from "@btst/stack/client"
export async function loader({ params }: Route.LoaderArgs) {
const path = normalizePath(params["*"])
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 1000 * 60 * 5, refetchOnMount: false, retry: false }
}
})
const stackClient = getStackClient(queryClient)
const route = stackClient.router.getRoute(path)
if (route?.loader) await route.loader()
const dehydratedState = dehydrate(queryClient)
return { path, dehydratedState, meta: route?.meta?.() }
}
export function meta({ loaderData }: Route.MetaArgs) {
return loaderData.meta
}
export default function PagesIndex() {
const { path, dehydratedState } = useLoaderData<typeof loader>()
const queryClient = useQueryClient()
const route = getStackClient(queryClient).router.getRoute(path)
const Page = route && route.PageComponent ? <route.PageComponent /> : <div>Route not found</div>
return dehydratedState ? (
<HydrationBoundary state={dehydratedState}>{Page}</HydrationBoundary>
) : Page
}
Navigate to /pages/blog. You now have:
Pages:
/pages/blog — Blog homepage/pages/blog/drafts — Draft management/pages/blog/new — Create new posts/pages/blog/:slug — Individual posts/pages/blog/:slug/edit — Edit posts/pages/blog/tag/:tagSlug — Tag filteringFeatures:
API:
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 tagsBetter Stack generates sitemaps from all registered plugins:
import type { Route } from "./+types/sitemap.xml"
import { QueryClient } from "@tanstack/react-query"
import { getStackClient } from "~/lib/better-stack-client"
import { sitemapEntryToXmlString } from "@btst/stack/client"
export async function loader({}: Route.LoaderArgs) {
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 lifecycle hooks:
import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api"
const blogHooks: BlogBackendHooks = {
onBeforeListPosts(filter, context) {
// Only admins can view drafts
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)
},
// ...
})
React Router's loader/action pattern is brilliant for data flow. But it doesn't solve feature reuse. Every project still rebuilds the same CRUD, the same forms, the same patterns.
Better Stack fills that gap. It provides horizontal slices — features that cut across data, API, and UI — designed to work with your framework's conventions, not against them.
This isn't about generating boilerplate. It's about composing production-ready features from well-designed modules. The blog plugin respects React Router's mental model: loaders prefetch, components render, actions mutate. It just handles the implementation.
Stop rebuilding infrastructure. Start building products.
Resources:
Powered by Better-Stack