BTST

Blog Plugin

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

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

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

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 - return false to deny access
  onBeforeListPosts(filter, context) {
    if(filter.published === false) {
        return isBlogAdmin(context.headers as Headers)
    }
    return true
  },
  onBeforeCreatePost(data, context) {
    return isBlogAdmin(context.headers as Headers)
  },
  onBeforeUpdatePost(postId, data, context) {
    return isBlogAdmin(context.headers as Headers)
  },
  onBeforeDeletePost(postId, context) {
    return isBlogAdmin(context.headers as Headers)
  },
  // ... 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) {
        return isAdmin(context.headers)
      }
      return true
    },
    afterLoadPost: async (post, slug, context) => {
      // only allow loading draft post for admin
      const isEditRoute = context.path?.includes('/edit');
      if (post?.published === false || isEditRoute) {
        return isAdmin(context.headers)
      }
      return true
    },
    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. This is helpful for SPA. Dont need for SSR as you should check auth in the loader.
      return true
    },
    // ... other hooks
  }
}}

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 functions for server-side and SSG use cases. These bypass the HTTP layer entirely and query the database directly.

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 are pre-bound to the adapter:

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

// In a Server Component, generateStaticParams, etc.
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();

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

Import getters directly and pass any Adapter:

import { getAllPosts, getPostBySlug, getAllTags } 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 }));
}

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

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.