BTST

Comments Plugin

Threaded comments with moderation, likes, replies, and embeddable CommentThread component

Full-stackReleased · Preview

Best for

Teams that need comments on posts, tasks, content records, or a custom resource without adopting a hosted discussion service.

Attach threaded discussion and moderation to a resource your application already owns.

Real BTST Comments moderation page showing an approved discussion attached to the shipping-plugin-catalog Blog resource.
A resource-bound comment passes through the real Comments API and appears in the supplied moderation workflow.

BTST supplies

  • Threaded comment and reaction data models with typed APIs and lifecycle hooks
  • Embeddable CommentThread and CommentCount components
  • A moderation route for pending, approved, and spam comments
  • Customizable hooks and ejectable moderation UI

You supply

  • A BTST database adapter
  • The resource type and identifier that each thread belongs to
  • Authorization rules and authoritative request identity when access is protected
  • A user resolver when author names and avatars should be displayed

You own and customize

Comments and reactions stay in your database. You define resource identity, access rules, and author resolution; the moderation page can be ejected.

Compatibility and dependencies

Maintained: Next.js 15+ App Router, React Router v7, TanStack Start.

Requires: A BTST database adapter; An adopter-owned host resource.

External services: None required.

From registration to result

A semantic workflow, not a setup shortcut

  1. 1Name the resource

    Mount CommentThread with the resource type and identifier owned by your app.

  2. 2Resolve identity

    Connect request identity, typed authorization rules, and optional author profiles.

  3. 3Discuss

    Create replies and reactions through the supplied typed APIs and UI.

  4. 4Moderate

    Review pending, approved, and spam comments on the built-in route.

Installation

Ensure you followed the general framework installation guide first.

1. Add Plugin to Backend API

Register the Comments plugin and the generic server authorization adapter in your stack.ts file. The adapter can wrap any session/authentication library; Comments has no dependency on it.

lib/stack.ts
import { createBackendStack } from "@btst/stack/api"
import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api"
import { serverAuth } from "./authorization.server"

const { handler, dbSchema } = createBackendStack({
  basePath: "/api/data",
  auth: serverAuth,
  plugins: {
    comments: commentsBackendPlugin({
      autoApprove: false,
      resolveUser: async (authorId) => {
        const user = await db.users.findById(authorId)
        return user
          ? { name: user.displayName, avatarUrl: user.avatarUrl }
          : null
      },
      hooks: {
        onAfterApproveComment: async (comment, ctx) => {
          await sendApprovalEmail(comment.authorId)
        },
      },
    })
  },
  adapter: (db) => createMemoryAdapter(db)({})
})

export { handler, dbSchema }

Lifecycle hooks run after authorization. Use them for domain invariants, logging, and side effects; the shared rule below owns ordinary authentication, ownership, and moderation decisions.

2. Add Plugin to Client

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

lib/stack-client.tsx
import { createClientStack } from "@btst/stack/client"
import { commentsClientPlugin } from "@btst/stack/plugins/comments/client"
import { QueryClient } from "@tanstack/react-query"

function getBaseURL(serverOrigin?: string) {
  if (typeof window !== "undefined") return window.location.origin
  return (
    serverOrigin ||
    process.env.BTST_SITE_URL ||
    process.env.BASE_URL ||
    "http://localhost:3000"
  )
}

export const getStackClient = (
  queryClient: QueryClient,
  options?: { headers?: Headers; origin?: string },
) => {
  const baseURL = getBaseURL(options?.origin)
  return createClientStack({
    api: {
      baseURL,
      basePath: "/api/data",
      ...(options?.headers ? { headers: options.headers } : {}),
    },
    site: { baseURL, basePath: "/pages" },
    queryClient,
    plugins: {
      comments: commentsClientPlugin({
        // optional loader lifecycle hooks:
        // hooks: {
        //   beforeLoadModeration: async (ctx) => { ... },
        //   beforeLoadUserComments: async (ctx) => { ... },
        //   onErrorLoad: async (error, ctx) => { ... },
        // },
      }),
    },
  })
}

Create a request-specific stack with request headers for SSR and a separate browser stack without them. StackProvider consumes the browser stack, so SSR loaders, metadata, hydration, browser hooks, and mutations share one resolved Comments endpoint and query client. Do not repeat transport or identity values in CommentsPluginOverrides or component props.

3. Add CSS Import

app/globals.css
@import "@btst/stack/plugins/comments/css";
app/app.css
@import "@btst/stack/plugins/comments/css";
src/styles/globals.css
@import "@btst/stack/plugins/comments/css";

4. Configure the Provider

Comments reads its resolved API and query client from the registered client stack. StackProvider adds framework routing and authorization; its inferred plugin override is only for Comments-specific presentation and behavior:

app/pages/client-layout.tsx
import { clientAuth } from "@/lib/authorization.client"

<StackProvider
  stack={clientStack}
  router={frameworkRouter}
  auth={clientAuth}
  initialIdentity={initialIdentity}
  overrides={{
    comments: {
      resourceLinks: {
        "blog-post": (slug) => `/pages/blog/${slug}`,
      },
    },
  }}
>
  {children}
</StackProvider>

The framework layout helpers can resolve initialIdentity on the server and hydrate it here. That avoids a duplicate identity request and keeps the server render and first browser render on the same rule result. See the authorization guide.

Authorization

Comments publishes a browser-safe, schema-backed catalog from @btst/stack/plugins/comments/permissions. Register it once and use the same rules in the browser and BTST backend:

lib/authorization.ts
import { defineAuthorization } from "@btst/stack/authorization"
import { commentsPermissions } from "@btst/stack/plugins/comments/permissions"
import { z } from "zod"

export const authorization = defineAuthorization({
  identity: z.object({
    id: z.string(),
    role: z.enum(["user", "moderator"]),
  }),
  permissions: [commentsPermissions] as const,
  rules: ({ comments }) => [
    comments.thread.read.when(({ identity, facts }) => {
      if (facts.scope === "public") return true
      if (facts.scope === "own")
        return identity?.id === facts.authorId || identity?.role === "moderator"
      return identity?.role === "moderator"
    }),
    comments.thread.createComment.when(({ identity }) => identity !== null),
    comments.comment.edit.when(({ identity, facts }) =>
      identity?.id === facts.authorId || identity?.role === "moderator"
    ),
    comments.comment.delete.when(({ identity, facts }) =>
      identity?.id === facts.authorId || identity?.role === "moderator"
    ),
    comments.comment.react.when(({ identity, facts }) =>
      identity !== null && facts.status === "approved"
    ),
    comments.comment.moderate.when(({ identity, facts }) =>
      identity?.role === "moderator" && facts.currentStatus !== facts.nextStatus
    ),
  ],
})
lib/authorization.client.ts
"use client"

import { createClientAuth } from "@btst/stack/authorization/client"
import { authorization } from "./authorization"

export const clientAuth = createClientAuth({
  authorization,
  getIdentity: () => browserSession?.user ?? null,
  loginPath: "/login",
})
lib/authorization.server.ts
import "server-only"

import { createServerAuth } from "@btst/stack/authorization/server"
import { authorization } from "./authorization"

export const serverAuth = createServerAuth({
  authorization,
  getIdentityFromHeaders: async ({ headers }) => {
    const session = await auth.api.getSession({ headers })
    return session?.user ?? null
  },
})

The public rule is deliberate: anonymous users can read approved threads and approved counts. Authenticated authors additionally see only their own pending comments in those threads. The own-history and moderation scopes are protected. Those row/status filters execute only on the server; boolean rules decide the coarse scope and never return query filters.

Browser facts are presentation-only. Before editing, deleting, reacting, or moderating, the backend reloads the comment's authoritative author, status, resource, and thread facts. Request authorship and like identity always come from the server adapter, even if an old RC caller sends an authorId. A security-relevant state change during evaluation returns HTTP 409 (COMMENT_STATE_CHANGED) instead of mutating with stale facts. Edit, reaction, moderation, and delete writes condition on the ownership, status, and resource facts their rule authorized, so overlapping changes cannot bypass the rule.

Once authorization is installed, a missing Comments rule denies that action. Omitting createBackendStack({ auth }) preserves permissive server behavior for applications that do not configure authorization. Use createServerAuth() to enable exact descriptor enforcement and default-deny missing rules. Client gates use the matching createClientAuth() adapter and exact descriptors shown above.

Embedding Comments

The CommentThread component can be embedded anywhere — below a blog post, inside a Kanban task dialog, or on a custom page. Data requests use the resolved endpoint of the registered comments plugin; identity and sign-in behavior use the auth service from the nearest StackProvider.

import { CommentThread } from "@btst/stack/plugins/comments/client/components"

<CommentThread
  resourceId={post.slug}          // Unique identifier for the resource being commented on
  resourceType="blog-post"        // Namespace — avoids ID collisions across resource types
  loginHref={`/sign-in?redirectTo=${encodeURIComponent(`/blog/${post.slug}#comments`)}`}
  components={{
    // Optional: replace the plain textarea with a rich editor
    Input: MarkdownEditor,
    // Optional: replace plain text rendering with markdown
    Renderer: MarkdownContent,
  }}
/>

CommentThread uses the published descriptors for the thread, create/reply, edit, delete, and react controls. These local checks avoid rendering controls the browser identity cannot use. They are not security boundaries: every HTTP and request-scoped backend call derives trusted facts and evaluates the same rule again.

Props

PropTypeRequiredDescription
resourceIdstringIdentifier for the resource (e.g. post slug, task ID)
resourceTypestringType of resource ("blog-post", "kanban-task", etc.)
loginHrefstringSign-in URL for unauthenticated users in this thread. Overrides the loginPath from the nearest StackProvider, which is useful for preserving a resource-specific return URL.
pageSizenumberComments per page. Falls back to defaultCommentPageSize from overrides, then 100. A "Load more" button appears when there are additional pages.
sort"asc" | "desc"Sort direction for top-level comments by createdAt. Defaults to defaultCommentSort from overrides, then "desc" (newest first). Replies inside each thread always render chronologically and are unaffected.
components.InputComponentTypeCustom input component (default: <textarea>)
components.RendererComponentTypeCustom renderer for comment body (default: <p>)

Blog Post Integration

The blog plugin exposes a postBottomSlot override that renders below every post:

import { CommentThread } from "@btst/stack/plugins/comments/client/components"

overrides={{
  blog: {
    postBottomSlot: (post) => (
      <CommentThread
        resourceId={post.slug}
        resourceType="blog-post"
      />
    ),
  }
}}

Kanban Task Integration

The Kanban plugin exposes a taskDetailBottomSlot override that renders at the bottom of the task detail dialog:

import { CommentThread } from "@btst/stack/plugins/comments/client/components"

overrides={{
  kanban: {
    taskDetailBottomSlot: (task) => (
      <CommentThread
        resourceId={task.id}
        resourceType="kanban-task"
      />
    ),
  }
}}

Comment Count Badge

Use CommentCount to show the number of approved comments anywhere (e.g., in a post listing):

import { CommentCount } from "@btst/stack/plugins/comments/client/components"

<CommentCount
  resourceId={post.slug}
  resourceType="blog-post"
/>

Moderation Dashboard

The comments plugin adds a /comments/moderation admin route with:

  • Tabbed views — Pending, Approved, Spam
  • Bulk actions — Approve, Mark as spam, Delete
  • Comment detail dialog — View full body and metadata
  • Per-row actions — Approve, spam, delete from the table row

Each selected queue is controlled by its exact comments.thread.read({ scope: "moderation", status, ... }) descriptor. Status still scopes the server query; the boolean rule only decides whether that scope is allowed. Each row and bulk action additionally evaluates exact comment.moderate or comment.delete facts. Moderation facts include both currentStatus and the requested nextStatus so an app can distinguish approval from marking spam.

Backend Configuration

commentsBackendPlugin Options

OptionTypeDefaultDescription
autoApprovebooleanfalseAutomatically approve new comments
allowPostingbooleantrueWhen false, the POST /comments endpoint is not registered (read-only comments mode).
allowEditingbooleantrueWhen false, the PATCH /comments/:id edit endpoint is not registered.
resolveUser(authorId: string) => Promise<{ name: string; avatarUrl?: string } | null>Map author IDs to display info; returns null → shows "[deleted]"
hooksCommentsBackendHooksPost-authorization domain lifecycle callbacks.

Pass lifecycle callbacks inside the hooks option:

CommentsBackendHooks fieldDescription
onBeforeListCommentsCalled after authorization and before a list query.
onBeforeCountCommentsCalled after authorization and before a count query.
onBeforeListCommentsByAuthorCalled after the own-history rule allows an author-scoped query.
onBeforeCreateCommentCalled after authorization and before a comment is saved. Request identity is authoritative when server authorization is configured.
onAfterCreateCommentCalled after a comment is saved.
onBeforeUpdateCommentCalled after authorization and before a comment body is updated.
onAfterUpdateCommentCalled after a comment body is updated.
onBeforeToggleCommentReactionCalled after authorization and before the request identity's reaction is toggled.
onBeforeModerateCommentCalled after authorization and before moderation status changes.
onAfterApproveCommentCalled after a comment is approved.
onBeforeDeleteCommentCalled after authorization and before a comment is deleted.
onAfterDeleteCommentCalled after a comment is deleted.

Comments lifecycle names use the action-first onBefore<Action><Entity> and onAfter<Action><Entity> grammar. Approval remains a distinct moderation event; it is not flattened into a generic update callback.

Removed nameCanonical name
onBeforeListonBeforeListComments
onBeforeCountonBeforeCountComments
onBeforeListByAuthoronBeforeListCommentsByAuthor
onBeforePostonBeforeCreateComment
onAfterPostonAfterCreateComment
onBeforeEditonBeforeUpdateComment
onAfterEditonAfterUpdateComment
onBeforeLikeonBeforeToggleCommentReaction
onBeforeStatusChangeonBeforeModerateComment
onAfterApproveonAfterApproveComment
onBeforeDeleteonBeforeDeleteComment
onAfterDeleteonAfterDeleteComment

All lifecycle hooks receive validated input, server-derived facts, resolved identity, and request in a deeply readonly context. Authorization runs first; denied requests cannot invoke hooks. Move earlier v3 RC role, ownership, and session checks into the shared rule. Hook return values never select request authorship. Request operations use the resolved identity; trusted and no-auth callers must provide an explicit authorId input instead.

After-hooks now receive JSON-safe serialized comments (ISO date strings), not mutable database rows containing Date instances. onBeforeCountComments is the count-specific lifecycle; onBeforeListComments remains list-specific.

Prop

Type

Prop

Type

Prop

Type

Request and trusted operations

Use the request-scoped API for application server code acting on behalf of a request. It runs the same validation, trusted fact loader, rule, lifecycle, and domain execution as HTTP:

const comments = app.forRequest(request).operations.comments

await comments.updateComment({
  id: commentId,
  data: { body: "Corrected text" },
})

Trusted jobs use the explicit trusted namespace. It skips user authorization only; validation, authoritative fact derivation, hooks, and domain behavior still run. Trusted creates must provide trusted authorship:

await app.trusted.comments.createComment({
  resourceId: "release-42",
  resourceType: "release",
  body: "Generated by the release job",
  authorId: "release-bot",
})

Trusted server calls

Comments has no raw stack.raw.comments business namespace. Use app.trusted.comments for explicitly trusted jobs that should keep operation validation and lifecycle hooks. Standalone getter exports remain lower-level adapter primitives for plugin internals and migrations.

React Hooks

Import hooks from @btst/stack/plugins/comments/client/hooks:

import {
  useComments,
  useCommentCount,
  usePostComment,
  useUpdateComment,
  useDeleteComment,
  useToggleLike,
  useUpdateCommentStatus,
} from "@btst/stack/plugins/comments/client/hooks"

// Fetch approved comments for a resource
const { data, isLoading } = useComments({
  resourceId: "my-post",
  resourceType: "blog-post",
  status: "approved",
})

// Post a new comment (includes optimistic update)
const { mutate: postComment } = usePostComment({
  resourceId: "my-post",
  resourceType: "blog-post",
})
postComment({
  body: "Great post!",
})

// Toggle like (one per user; optimistic update)
const { mutate: toggleLike } = useToggleLike({
  resourceId: "my-post",
  resourceType: "blog-post",
  parentId: null,
})
toggleLike({ commentId: "comment-id" })

// Moderate a comment
const { mutate: updateStatus } = useUpdateCommentStatus()
updateStatus({ id: "comment-id", status: "approved" })

User Comments Page

The comments plugin registers a /comments route that shows the current user's full comment history — all statuses (approved, pending, spam) in a single paginated table, newest first.

Features:

  • All comment statuses visible to the owner in one list, each with an inline status badge
  • Prev / Next pagination (20 per page)
  • Resource link column — click through to the original resource when resourceLinks is configured (links automatically include #comments so the page scrolls to the comment thread)
  • Delete button with confirmation dialog — calls DELETE /comments/:id (governed by onBeforeDeleteComment)
  • Login prompt when the top-level auth provider resolves no identity

Setup

Pass the resolved browser stack and keep only Comments-specific overrides in your layout:

app/pages/client-layout.tsx
<StackProvider
  stack={clientStack}
  auth={clientAuth}
  initialIdentity={initialIdentity}
  overrides={{
    comments: {

      // Map resource types to URLs so comments link back to their resource
      resourceLinks: {
        "blog-post": (slug) => `/pages/blog/${slug}`,
        "kanban-task": (id) => `/pages/kanban?task=${id}`,
      },

    },
  }}
>
  {children}
</StackProvider>

The route requests comments.thread.read({ scope: "own", authorId }). The backend evaluates that scope against request identity and applies the author filter itself; the browser cannot reveal another user's history by changing a query parameter.

API Reference

Client Plugin Factory

commentsClientPlugin(config?) accepts only Comments-specific loader hooks. Shared runtime belongs to createClientStack.

FieldTypeRequiredDescription
hooks.beforeLoadModeration(context) => Promise<void> | voidCalled before moderation page loader logic runs. Throw to cancel.
hooks.beforeLoadUserComments(context) => Promise<void> | voidCalled before User Comments page loader logic runs. Throw to cancel. Optionally set context.currentUserId so SSR prefetch/error-seeding uses the same user-scoped cache key as the page query.
hooks.onErrorLoad(error, context) => Promise<void> | voidReports a loader failure once. Reporter errors are contained so SSR loaders do not reject.

Client Plugin Overrides

Configure the comments plugin behavior from your layout:

CommentsPluginOverrides

FieldTypeDescription
localizationPartial<CommentsLocalization>Override any UI string in the plugin. Import COMMENTS_LOCALIZATION from @btst/stack/plugins/comments/client to see all available keys.
showAttributionbooleanShow/hide the "Powered by BTST" attribution on plugin pages (defaults to true).
defaultCommentPageSizenumberDefault number of top-level comments per page for all CommentThread instances. Overridden per-instance by the pageSize prop. Defaults to 100 when not set.
defaultCommentSort"asc" | "desc"Default sort direction for top-level comments in all CommentThread instances. Overridden per-instance by the sort prop. Defaults to "desc" (newest first).
allowPostingbooleanHide/show comment form and reply actions globally in CommentThread instances (defaults to true).
allowEditingbooleanHide/show edit affordances globally in CommentThread instances (defaults to true).
resourceLinksRecord<string, (id: string) => string>Per-resource-type URL builders for linking comments back to their resource on the User Comments page (e.g. { "blog-post": (slug) => "/pages/blog/" + slug }). The plugin appends #comments automatically so the page scrolls to the thread.
onRouteRender(routeName, context) => void | Promise<void>Called when a comments route renders.
onRouteError(routeName, error, context) => void | Promise<void>Called when a comments route hits an error.

HTTP Endpoints

Every maintained HTTP endpoint is an adapter over the operation with the same name in forRequest(request).operations.comments and trusted.comments:

MethodPathOperationPermission
GET/commentslistCommentscomments:thread.read
POST/commentscreateCommentcomments:thread.createComment
PATCH/comments/:idupdateCommentcomments:comment.edit
GET/comments/countgetCommentCountcomments:thread.read
POST/comments/:id/liketoggleLikecomments:comment.react
PATCH/comments/:id/statusupdateCommentStatuscomments:comment.moderate
DELETE/comments/:iddeleteCommentcomments:comment.delete

SerializedComment

Comments returned by the API include resolved author information:

FieldTypeDescription
idstringComment ID
resourceIdstringResource identifier
resourceTypestringResource type
parentIdstring | nullParent comment ID for replies
authorIdstringAuthor user ID
resolvedAuthorNamestringDisplay name from resolveUser, or "[deleted]"
resolvedAvatarUrlstring | nullAvatar URL from resolveUser
bodystringComment body
status"pending" | "approved" | "spam"Moderation status
likesnumberDenormalized like count
isLikedByCurrentUserbooleanWhether the requesting user has liked this comment
editedAtstring | nullISO date string if the comment was edited
createdAtstringISO date string
updatedAtstringISO date string