BTST logoBTST
BlogGitHubDocs
BTST logoBTST

Full-stack features as npm packages. Install a plugin, get routes, APIs, schemas, and UI.

Plugins

  • AI Chat
  • Auth UI
  • Blog
  • CMS
  • Form Builder
  • Kanban
  • OpenAPI
  • Route Docs
  • UI Builder

Resources

  • Documentation
  • All Plugins
  • Blog
  • GitHub↗

Get Started

Ready to ship faster? Install the package and add your first plugin in under 5 minutes.

npm i @btst/stack
Read the docs

© 2026 BTST. Open source under MIT License.

Built by @olliethedev
November 28, 2025Web DevelopmentReact Router

The Easiest Way to Add a Blog to React Router in 2026

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.

The Easiest Way to Add a Blog to React Router in 2026

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.

Modularity Over Repetition#

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.

Prerequisites#

Your React Router project needs:

  • shadcn/ui installed with CSS variables enabled
  • Sonner <Toaster /> for notifications
  • TailwindCSS v4 configured
  • @tanstack/react-query installed

Step 1: Install Better Stack#

Install 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 testing
BASH
  1. 1
npm install @btst/stack @tanstack/react-query @btst/adapter-prisma

Step 2: Create the Backend Instance#

Set up lib/better-stack.ts with the blog plugin:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
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.

Step 3: Create the API Route#

React Router uses file-based routing. Create a catch-all route for Better Stack:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
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.

Step 4: Generate the Database Schema#

Run the CLI to generate Prisma schema additions:

BASH
  1. 1
npx @btst/cli generate --config=lib/better-stack.ts --orm=prisma --output=prisma/schema.prisma

Apply the migration:

BASH
  1. 1
npx prisma migrate dev --name add-blog

Step 5: Set Up the Client#

Create lib/better-stack-client.tsx for client-side routing and data fetching:

TSX
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
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`,
        },
      })
    }
  })
}

Step 6: Configure React Query#

Create a query client utility:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
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:

TSX
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
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>
  )
}

Step 7: Import Plugin Styles#

Add the blog CSS to your stylesheet:

CSS
  1. 1
@import "@btst/stack/plugins/blog/css";

Step 8: Create the Layout Provider#

Set up React Router-specific overrides:

TSX
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
  42. 42
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>
  )
}

Step 9: Create the Page Handler#

This route handles all blog pages with SSR data loading:

TSX
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
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
}

Your Blog is Ready#

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 filtering

Features:

  • Server-side data loading with React Router loaders
  • SEO meta tags via React Router's meta function
  • Markdown editor with live preview
  • Draft/publish workflow
  • Tag management
  • Hydrated client state

API:

  • GET /api/data/posts — List with filtering
  • POST /api/data/posts — Create
  • PUT /api/data/posts/:id — Update
  • DELETE /api/data/posts/:id — Delete
  • GET /api/data/tags — List tags

Adding Sitemap Support#

Better Stack generates sitemaps from all registered plugins:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
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",
    },
  })
}

Adding Authorization#

Protect your blog with lifecycle hooks:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
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)
  },
  // ...
})

The Bigger Picture#

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:

  • Full Installation Guide — Complete setup instructions for all frameworks and adapters
  • Better Stack Documentation
  • Blog Plugin Reference
  • Database Adapters — Detailed configuration for Prisma, Drizzle, Kysely, and MongoDB
  • React Router Example Repository

In This Post

Modularity Over RepetitionPrerequisitesStep 1: Install Better StackStep 2: Create the Backend InstanceStep 3: Create the API RouteStep 4: Generate the Database SchemaStep 5: Set Up the ClientStep 6: Configure React QueryStep 7: Import Plugin StylesStep 8: Create the Layout ProviderStep 9: Create the Page HandlerYour Blog is ReadyAdding Sitemap SupportAdding AuthorizationThe Bigger Picture