BTST

Blog Plugin

Content management, editor, drafts, publishing, SEO and more

Blog Plugin Demo - HomeBlog Plugin Demo - PostBlog Plugin Demo - EditorBlog Plugin Demo - Drafts

View interactive demo →

Installation

Ensure you followed the general framework installation guide first.

Follow these steps to add the Blog plugin to your BTST setup.

1. Add Plugin to Backend API

Import and register the blog backend plugin in your stack.ts file:

lib/stack.ts
import { stack } from "@btst/stack"
import { blogBackendPlugin } from "@btst/stack/plugins/blog/api"
// ... your adapter imports

const { handler, dbSchema } = stack({
  basePath: "/api/data",
  plugins: {
    blog: blogBackendPlugin()
  },
  adapter: (db) => createPrismaAdapter(prisma, db, { 
    provider: "postgresql" 
  })
})

export { handler, dbSchema }

The blogBackendPlugin() accepts optional hooks for customizing behavior (authorization, logging, etc.).

2. Add Plugin to Client

Register the blog client plugin in your stack-client.tsx file:

lib/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 = () => 
   (process.env.BASE_URL || "http://localhost:3000")

export const getStackClient = (queryClient: QueryClient) => {
  const baseURL = getBaseURL()
  return createStackClient({
    plugins: {
      blog: blogClientPlugin({
        // Required configuration
        apiBaseURL: baseURL,
        apiBasePath: "/api/data",
        siteBaseURL: baseURL,
        siteBasePath: "/pages",
        queryClient: queryClient,
        // Optional: SEO configuration
        seo: {
          siteName: "My Blog",
          author: "Your Name",
          twitterHandle: "@yourhandle",
          locale: "en_US",
          defaultImage: `${baseURL}/og-image.png`,
        },
      })
    }
  })
}

Required configuration:

  • apiBaseURL: Base URL for API calls during SSR data prefetching (use environment variables for flexibility)
  • apiBasePath: Path where your API is mounted (e.g., /api/data)
  • siteBaseURL: Base URL of your site
  • siteBasePath: Path where your pages are mounted (e.g., /pages)
  • queryClient: React Query client instance

Why configure API paths here? This configuration is used by server-side loaders that prefetch data before your pages render. These loaders run outside of React Context, so they need direct configuration. You'll also provide apiBaseURL and apiBasePath again in the Provider overrides (Section 4) for client-side components that run during actual rendering.

3. Import Plugin CSS

Add the blog plugin CSS to your global stylesheet:

app/globals.css
@import "@btst/stack/plugins/blog/css";

This includes all necessary styles for the blog components, markdown rendering, and editor.

4. Add Context Overrides

Configure framework-specific overrides in your StackProvider:

app/pages/[[...all]]/layout.tsx
import { StackProvider } from "@btst/stack/context"
import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
import Link from "next/link"
import Image from "next/image"
import { useRouter } from "next/navigation"

const getBaseURL = () => 
  typeof window !== 'undefined' 
    ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin)
    : (process.env.BASE_URL || "http://localhost:3000")

type PluginOverrides = {
  blog: BlogPluginOverrides
}

export default function Layout({ children }) {
  const router = useRouter()
  const baseURL = getBaseURL()
  
  return (
    <StackProvider<PluginOverrides>
      basePath="/pages"
      overrides={{
        blog: {
          apiBaseURL: baseURL,
          apiBasePath: "/api/data",
          navigate: (path) => router.push(path),
          refresh: () => router.refresh(),
          uploadImage: async (file) => {
            // Implement your image upload logic
            // Return the URL of the uploaded image
            return "https://example.com/uploads/image.jpg"
          },
          Link: (props) => <Link {...props} />,
          Image: (props) => <Image {...props} />,
        }
      }}
    >
      {children}
    </StackProvider>
  )
}
app/routes/pages/_layout.tsx
import { Outlet, Link, useNavigate } from "react-router"
import { StackProvider } 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 (
    <StackProvider<PluginOverrides>
      basePath="/pages"
      overrides={{
        blog: {
          apiBaseURL: baseURL,
          apiBasePath: "/api/data",
          navigate: (href) => navigate(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 />
    </StackProvider>
  )
}
src/routes/pages/route.tsx
import { StackProvider } from "@btst/stack/context"
import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client"
import { Link, useRouter, Outlet } 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
}

function Layout() {
  const router = useRouter()
  const baseURL = getBaseURL()

  return (
    <StackProvider<PluginOverrides>
      basePath="/pages"
      overrides={{
        blog: {
          apiBaseURL: baseURL,
          apiBasePath: "/api/data",
          navigate: (href) => router.navigate({ 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 />
    </StackProvider>
  )
}

Required overrides:

  • apiBaseURL: Base URL for API calls (used by client-side components during rendering)
  • apiBasePath: Path where your API is mounted
  • navigate: Function for programmatic navigation
  • uploadImage: Function to upload images and return their URL

Optional overrides:

  • Link: Custom Link component (defaults to <a> tag)
  • Image: Custom Image component (useful for Next.js Image optimization)
  • refresh: Function to refresh server-side cache (useful for Next.js)
  • localization: Custom localization strings
  • showAttribution: Whether to show BTST attribution

Why provide API paths again? You already configured these in Section 2, but that configuration is only available to server-side loaders. The overrides here provide the same values to client-side components (like hooks, forms, and UI) via React Context. These two contexts serve different phases: loaders prefetch data server-side before rendering, while components use data during actual rendering (both SSR and CSR).

5. Generate Database Schema

After adding the plugin, generate your database schema using the CLI:

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

This will create the necessary database tables for posts and tags. Run migrations as needed for your ORM.

For more details on the CLI and all available options, see the CLI documentation.

Congratulations, You're Done! 🎉

Your blog plugin is now fully configured and ready to use! Here's a quick reference of what's available:

API Endpoints

The blog plugin provides the following API endpoints (mounted at your configured apiBasePath):

  • GET /posts - List posts with optional filtering (published status, tag, search query)
  • POST /posts - Create a new post
  • PUT /posts/:id - Update an existing post
  • DELETE /posts/:id - Delete a post
  • GET /posts/next-previous - Get previous and next posts relative to a date
  • GET /tags - List all tags

Page Routes

The blog plugin automatically creates the following pages (mounted at your configured siteBasePath):

  • /blog - Blog homepage with published posts
  • /blog/drafts - Draft posts page
  • /blog/new - Create new post page
  • /blog/:slug - Individual post page
  • /blog/:slug/edit - Edit post page
  • /blog/tag/:tagSlug - Posts filtered by tag

Page Component Overrides

You can replace any built-in page with your own React component using the optional pageComponents field in blogClientPlugin(config). The built-in component is used as the fallback whenever an override is not provided, so this is fully backward-compatible.

blogClientPlugin({
  // ... other config
  pageComponents: {
    // Replace the published posts list page
    posts: MyCustomPostsPage,
    // Replace the single post page — receives the slug as a prop
    post: ({ slug }) => <MyCustomPostPage slug={slug} />,
    // Replace the edit post page — receives the slug as a prop
    editPost: ({ slug }) => <MyCustomEditPage slug={slug} />,
    // Replace the tag page — receives tagSlug as a prop
    tag: ({ tagSlug }) => <MyCustomTagPage tagSlug={tagSlug} />,
    // Replace the drafts list page
    drafts: MyCustomDraftsPage,
    // Replace the new post page
    newPost: MyCustomNewPostPage,
  },
})

Adding Authorization

To add authorization rules and customize behavior, you can use the lifecycle hooks defined in the API Reference section below. These hooks allow you to control access to API endpoints, add logging, and customize the plugin's behavior to fit your application's needs.

API Reference

Backend (@btst/stack/plugins/blog/api)

blogBackendPlugin

Prop

Type

BlogBackendHooks

Customize backend behavior with optional lifecycle hooks. All hooks are optional and allow you to add authorization, logging, and custom behavior:

Prop

Type

Example usage:

lib/stack.ts
import { blogBackendPlugin, type BlogBackendHooks } from "@btst/stack/plugins/blog/api"

const blogHooks: BlogBackendHooks = {
  // Authorization hooks — throw to deny access
  async onBeforeListPosts(filter, context) {
    if (filter.published === false) {
      if (!await isBlogAdmin(context.headers as Headers))
        throw new Error("Admin access required to view drafts")
    }
  },
  async onBeforeCreatePost(data, context) {
    if (!await isBlogAdmin(context.headers as Headers))
      throw new Error("Admin access required to create posts")
  },
  async onBeforeUpdatePost(postId, data, context) {
    if (!await isBlogAdmin(context.headers as Headers))
      throw new Error("Admin access required to update posts")
  },
  async onBeforeDeletePost(postId, context) {
    if (!await isBlogAdmin(context.headers as Headers))
      throw new Error("Admin access required to delete posts")
  },
  // ... other hooks
}

const { handler, dbSchema } = stack({
  plugins: {
    blog: blogBackendPlugin(blogHooks)
  },
  // ...
})

BlogApiContext

Prop

Type

Client (@btst/stack/plugins/blog/client)

blogClientPlugin

Prop

Type

BlogClientConfig

The client plugin accepts a configuration object with required fields and optional SEO settings:

Prop

Type

Example usage:

lib/stack-client.tsx
blog: blogClientPlugin({
  // Required configuration
  apiBaseURL: baseURL,
  apiBasePath: "/api/data",
  siteBaseURL: baseURL,
  siteBasePath: "/pages",
  queryClient: queryClient,
  // Optional SEO configuration
  seo: {
    siteName: "My Awesome Blog",
    author: "John Doe",
    twitterHandle: "@johndoe",
    locale: "en_US",
    defaultImage: `${baseURL}/og-image.png`,
  },
})

BlogClientHooks

Customize client-side behavior with lifecycle hooks. These hooks are called during data fetching (both SSR and CSR):

Prop

Type

Example usage:

lib/stack-client.tsx
blog: blogClientPlugin({
  // ... rest of the config
  headers: options?.headers,
  hooks: {
    beforeLoadPosts: async (filter, context) => {
      // only allow loading draft posts for admin
      if (!filter.published) {
        if (!await isAdmin(context.headers))
          throw new Error("Admin access required to view drafts")
      }
    },
    afterLoadPost: async (post, slug, context) => {
      // only allow loading draft post for admin
      const isEditRoute = context.path?.includes('/edit');
      if (post?.published === false || isEditRoute) {
        if (!await isAdmin(context.headers))
          throw new Error("Admin access required")
      }
    },
    onLoadError(error, context) {
      //handle error during prefetching
      redirect("/auth/sign-in")
    },
    // ... other hooks
  }
})

RouteContext

Prop

Type

LoaderContext

Prop

Type

BlogPluginOverrides

Configure framework-specific overrides and route lifecycle hooks. All lifecycle hooks are optional:

Prop

Type

Example usage:

overrides={{
  blog: {
    // Required overrides
    apiBaseURL: baseURL,
    apiBasePath: "/api/data",
    navigate: (path) => router.push(path),
    uploadImage: async (file) => {
      // Implement your image upload logic
      return "https://example.com/uploads/image.jpg"
    },
    // Optional lifecycle hooks
    onBeforePostsPageRendered: (context) => {
      // Check if user can view posts list. Helpful for SPA; not needed for SSR (check auth in the loader instead).
      // Throw to deny: throw new Error("Unauthorized")
    },
    // ... other hooks
  }
}}

Slot overrides:

OverrideTypeDescription
postBottomSlot(post: SerializedPost) => ReactNodeRender additional content below each blog post — use to embed a CommentThread
import { CommentThread } from "@btst/stack/plugins/comments/client/components"

overrides={{
  blog: {
    // ...
    postBottomSlot: (post) => (
      <CommentThread
        resourceId={post.slug}
        resourceType="blog-post"
        apiBaseURL={baseURL}
        apiBasePath="/api/data"
        currentUserId={session?.user?.id}
        loginHref="/login"
      />
    ),
  }
}}

React Data Hooks and Types

You can import the hooks from "@btst/stack/plugins/blog/client/hooks" to use in your components.

UsePostsOptions

Prop

Type

UsePostsResult

Prop

Type

UsePostResult

Prop

Type

UsePostSearchOptions

Prop

Type

UsePostSearchResult

Prop

Type

UseNextPreviousPostsOptions

Prop

Type

UseNextPreviousPostsResult

Prop

Type

UseRecentPostsOptions

Prop

Type

UseRecentPostsResult

Prop

Type

PostCreateInput

Prop

Type

PostUpdateInput

Prop

Type

Server-side Data Access

The blog plugin exposes standalone getter and mutation functions for server-side use cases. These bypass the HTTP layer entirely and query the database directly — no authorization hooks are called, so the caller is responsible for any access-control checks.

Two patterns

Pattern 1 — via stack().api (recommended for runtime server code)

After calling stack(), the returned object includes a fully-typed api namespace. Getters and mutations are pre-bound to the adapter:

app/lib/stack.ts
import { myStack } from "./stack"; // your stack() instance

// Getters — read-only
const result = await myStack.api.blog.getAllPosts({ published: true });
// result.items  — Post[]
// result.total  — total count before pagination
// result.limit  — applied limit
// result.offset — applied offset

const post = await myStack.api.blog.getPostBySlug("hello-world");
const tags = await myStack.api.blog.getAllTags();

// Mutations — write operations (no auth hooks are called)
const newPost = await myStack.api.blog.createPost({
  title: "Hello World",
  slug: "hello-world",
  content: "...",
  excerpt: "...",
});
await myStack.api.blog.updatePost(newPost.id, { published: true });
await myStack.api.blog.deletePost(newPost.id);

Pattern 2 — direct import (SSG, build-time, or custom adapter)

Import getters and mutations directly and pass any Adapter:

import { getAllPosts, createPost, updatePost, deletePost } from "@btst/stack/plugins/blog/api";

// e.g. in Next.js generateStaticParams
export async function generateStaticParams() {
  const { items } = await getAllPosts(myAdapter, { published: true });
  return items.map((p) => ({ slug: p.slug }));
}

// e.g. seeding or scripting
const post = await createPost(myAdapter, {
  title: "Seeded Post",
  slug: "seeded-post",
  content: "Content here",
  excerpt: "Short excerpt",
});
await updatePost(myAdapter, post.id, { published: true });

No authorization hooks are called when using stack().api.* or direct imports. These functions hit the database directly. Always perform your own access-control checks before calling them from user-facing code.

Available getters

FunctionReturnsDescription
getAllPosts(adapter, params?)PostListResultPaginated posts matching optional filter params
getPostBySlug(adapter, slug)Post | nullSingle post by slug, or null if not found
getAllTags(adapter)Tag[]All tags, sorted alphabetically

PostListParams

Prop

Type

PostListResult

Prop

Type

Available mutations

FunctionReturnsDescription
createPost(adapter, input)PostCreate a new post with optional tag associations
updatePost(adapter, id, input)Post | nullUpdate a post and reconcile its tags; null if not found
deletePost(adapter, id)voidDelete a post by ID

CreatePostInput

Prop

Type

UpdatePostInput

Prop

Type

Static Site Generation (SSG)

route.loader() makes HTTP requests to apiBaseURL, which silently fails during next build because no dev server is running. Use prefetchForRoute() instead — it reads directly from the database and pre-populates the React Query cache before rendering.

prefetchForRoute(routeKey, queryClient, params?)

Route keyParams requiredData prefetched
"posts"Published posts list
"drafts"Draft posts list
"post"{ slug: string }Single post detail
"tag"{ tagSlug: string }Tag + tagged posts
"newPost"(nothing)
"editPost"{ slug: string }Post to edit

Next.js example

app/pages/blog/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { getOrCreateQueryClient } from "@/lib/query-client"
import { getStackClient } from "@/lib/stack-client"
import { myStack } from "@/lib/stack"
import { metaElementsToObject, normalizePath } from "@btst/stack/client"
import type { Metadata } from "next"

// Opt into SSG — Next.js generates this page at build time
export async function generateStaticParams() {
  return [{}]
}

// export const revalidate = 3600 // uncomment for ISR (1 hour)

export async function generateMetadata(): Promise<Metadata> {
  const queryClient = getOrCreateQueryClient()
  const stackClient = getStackClient(queryClient)
  const route = stackClient.router.getRoute(normalizePath(["blog"]))
  if (!route) return { title: "Blog" }
  await myStack.api.blog.prefetchForRoute("posts", queryClient)
  return metaElementsToObject(route.meta?.() ?? []) satisfies Metadata
}

export default async function BlogListPage() {
  const queryClient = getOrCreateQueryClient()
  const stackClient = getStackClient(queryClient)
  const route = stackClient.router.getRoute(normalizePath(["blog"]))
  if (!route) return null
  // Reads directly from DB — works at build time, no HTTP server required
  await myStack.api.blog.prefetchForRoute("posts", queryClient)
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <route.PageComponent />
    </HydrationBoundary>
  )
}

For individual post pages, also generate the static params list:

app/pages/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const { items } = await myStack.api.blog.getAllPosts({ published: true, limit: 1000 })
  return items.map((p) => ({ slug: p.slug }))
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const queryClient = getOrCreateQueryClient()
  const stackClient = getStackClient(queryClient)
  const route = stackClient.router.getRoute(normalizePath(["blog", params.slug]))
  if (!route) return null
  await myStack.api.blog.prefetchForRoute("post", queryClient, { slug: params.slug })
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <route.PageComponent />
    </HydrationBoundary>
  )
}

ISR cache invalidation

If you use Incremental Static Regeneration, the static page cache must be purged whenever content changes. Wire up revalidatePath (or revalidateTag) inside the backend lifecycle hooks so Next.js regenerates the page on the next request:

lib/stack.ts
import { revalidatePath } from "next/cache"
import type { BlogBackendHooks } from "@btst/stack/plugins/blog"

const blogHooks: BlogBackendHooks = {
  onPostCreated: async (post) => {
    revalidatePath("/blog")
    revalidatePath(`/blog/${post.slug}`)
  },
  onPostUpdated: async (post) => {
    revalidatePath("/blog")
    revalidatePath(`/blog/${post.slug}`)
  },
  onPostDeleted: async (postId) => {
    revalidatePath("/blog")
  },
}

revalidatePath / revalidateTag are Next.js APIs — import them from "next/cache". They are no-ops outside of a Next.js runtime, so this pattern is safe to use in the lib/stack.ts shared file without breaking other frameworks.

Query key consistency

prefetchForRoute uses the same query key shapes as createBlogQueryKeys (the HTTP client). The shared constants live in @btst/stack/plugins/blog/api as BLOG_QUERY_KEYS and postsListDiscriminator, so the two paths can never drift silently.

Shadcn Registry

The Blog plugin UI layer is distributed as a shadcn registry block. Use the registry to eject and fully customize the page components while keeping all data-fetching and API logic from @btst/stack.

The registry installs only the view layer. Hooks and data-fetching continue to come from @btst/stack/plugins/blog/client/hooks.

npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-blog.json
pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-blog.json
bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-blog.json

This copies the page components into src/components/btst/blog/client/ in your project. All relative imports remain valid and you can edit the files freely — the plugin's data layer stays intact.

Using ejected components

After installing, wire your custom components into the plugin via the pageComponents option in your client plugin config:

lib/stack-client.tsx
import { blogClientPlugin } from "@btst/stack/plugins/blog/client"
// Import your ejected (and customized) page components
import { HomePageComponent } from "@/components/btst/blog/client/components/pages/home-page"
import { PostPageComponent } from "@/components/btst/blog/client/components/pages/post-page"

blogClientPlugin({
  apiBaseURL: "...",
  apiBasePath: "/api/data",
  siteBaseURL: "...",
  siteBasePath: "/pages",
  queryClient,
  pageComponents: {
    posts: HomePageComponent,       // replaces the published posts list page
    post: PostPageComponent,        // replaces the single post page
    // drafts, newPost, editPost, tag — omit to keep built-in defaults
  },
})

Any key you omit falls back to the built-in default, so you can override just the pages you want to change.