Blog Plugin
Content management, editor, drafts, publishing, SEO and more
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:
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:
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 sitesiteBasePath: 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:
@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:
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>
)
}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>
)
}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 mountednavigate: Function for programmatic navigationuploadImage: 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 stringsshowAttribution: 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.prismaThis 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:
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:
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:
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:
| Override | Type | Description |
|---|---|---|
postBottomSlot | (post: SerializedPost) => ReactNode | Render 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:
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
| Function | Returns | Description |
|---|---|---|
getAllPosts(adapter, params?) | PostListResult | Paginated posts matching optional filter params |
getPostBySlug(adapter, slug) | Post | null | Single post by slug, or null if not found |
getAllTags(adapter) | Tag[] | All tags, sorted alphabetically |
PostListParams
Prop
Type
PostListResult
Prop
Type
Available mutations
| Function | Returns | Description |
|---|---|---|
createPost(adapter, input) | Post | Create a new post with optional tag associations |
updatePost(adapter, id, input) | Post | null | Update a post and reconcile its tags; null if not found |
deletePost(adapter, id) | void | Delete 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 key | Params required | Data 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
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:
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:
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.jsonpnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-blog.jsonbunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-blog.jsonThis 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:
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.



