First Class Modules
Discussion Boards
Provides boards, topics, threaded posts, replies, pin/lock moderation, archives, owned-post editing, custom fields, relations, activity, and favorites.
module.discussion-boardsUse Discussion Boards when collaboration needs named boards, durable topics, threaded replies, and moderation rather than a continuous chat stream.
The bundled renderer key is module.discussion-boards. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.
What it does
Provides boards, topics, threaded posts, replies, pin/lock moderation, archives, owned-post editing, custom fields, relations, activity, and favorites.
Key capabilities
- Search active or archived boards and browse topic/post counts.
- Create boards and topics, reply to topics, and edit owned or otherwise authorized content.
- Pin or lock topics and archive boards with the required moderation capability.
- Favorite boards/topics and show secured relations, activity, location, and dynamic fields.
Common uses
- Customer or partner communities.
- Internal Q&A, proposals, and long-running technical discussions.
- Program, project, or policy forums that benefit from topic-level history.
How it connects
Boards and topics can relate to Knowledge Articles, Files, projects, products, or cases. A resolved discussion can become maintained knowledge while the original thread remains available as evidence.
Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.
Security and data boundary
Board, topic, and post operations enforce tenant, DataRole, location, ownership, and moderation rules. Locked topics refuse ordinary replies, and edit controls reflect secured response capabilities.
- The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
- The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
- List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.
Add it to an app
- Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
- Add the validated
module.discussion-boardsmodule block through the supported page/template authoring flow. - Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
- Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
- Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.
Copy/paste the Discussion Boards page
The native block supplies secured board search, board/topic/post detail, create/edit, topic pin/lock moderation, replies, archive state, custom fields, relations, activity, and favorites:
{
"blocks": [
{ "_id": "community-discussions", "_type": "module.discussion-boards", "props": {}, "children": [] }
]
}
Custom unstyled board and topic browser
import { FormEvent, useEffect, useState } from "react";
import type { DiscussionBoardDetail, DiscussionBoardsList, DiscussionTopicDetail } from "@buildwithhq/module-sdk";
import { getDiscussionBoard, getDiscussionTopic, listDiscussionBoards } from "./api";
const empty: DiscussionBoardsList = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, canCreate: false, canEdit: false, items: [] };
export function UnstyledDiscussionBoards({ locationId = "" }) {
const [draft, setDraft] = useState(""); const [search, setSearch] = useState(""); const [page, setPage] = useState(1);
const [boards, setBoards] = useState<DiscussionBoardsList>(empty); const [board, setBoard] = useState<DiscussionBoardDetail | null>(null); const [topic, setTopic] = useState<DiscussionTopicDetail | null>(null);
useEffect(() => { const controller = new AbortController(); listDiscussionBoards(search, false, controller.signal, locationId, page, 50).then(setBoards); return () => controller.abort(); }, [locationId, page, search]);
async function openBoard(recordId: string) { setBoard(await getDiscussionBoard(recordId)); setTopic(null); }
async function openTopic(recordId: string) { setTopic(await getDiscussionTopic(recordId)); }
return <section className="custom-boards"><h1>Discussion Boards</h1><form role="search" onSubmit={(e: FormEvent) => { e.preventDefault(); setPage(1); setSearch(draft.trim()); }}><input aria-label="Search boards" value={draft} onChange={e => setDraft(e.target.value)} /><button>Search</button></form>
<ul>{boards.items.map(item => <li key={item.recordId}><button onClick={() => void openBoard(item.recordId)}><strong>{item.boardName}</strong><span>{item.topicCount} topics - {item.postCount} posts</span></button></li>)}</ul>
<nav><button disabled={page <= 1} onClick={() => setPage(value => value - 1)}>Previous</button><span>Page {boards.pageNumber} of {Math.max(1, boards.totalPages)}</span><button disabled={page >= boards.totalPages} onClick={() => setPage(value => value + 1)}>Next</button></nav>
{board && <article><h2>{board.boardName}</h2><p>{board.description}</p><ol>{board.topics.map(item => <li key={item.recordId}><button onClick={() => void openTopic(item.recordId)}>{item.isPinned ? "Pinned: " : ""}{item.topicTitle} ({item.postCount})</button></li>)}</ol></article>}
{topic && <article><h2>{topic.topicTitle}</h2>{topic.posts.map(post => <section key={post.recordId}><strong>{post.postedByDisplayName || "Member"}</strong><p>{post.postBody}</p></section>)}</article>}
</section>;
}
Create boards and topics, moderate, reply, and edit
Board, topic, and post edits are separate optimistic commands. Locked topics refuse ordinary replies; archived boards refuse new work. canEdit, canCreateTopic, canReply, and each post's canEdit are server decisions.
import type { DiscussionBoardDetail, DiscussionPost, DiscussionTopicDetail } from "@buildwithhq/module-sdk";
import { createDiscussionBoard, createDiscussionTopic, getDiscussionBoard, getDiscussionTopic, postDiscussionReply, setDiscussionFavorite, updateDiscussionBoard, updateDiscussionPost, updateDiscussionTopic } from "./api";
export const createBoard = (locationId?: string) => createDiscussionBoard({ boardName: "Product questions", description: "Durable product discussions", locationId, dynamicFields: [] });
export async function archiveBoard(board: DiscussionBoardDetail) { await updateDiscussionBoard(board.recordId, { expectedUpdatedUtc: board.updatedUtc, boardName: board.boardName, description: board.description || "", isArchived: true, locationId: board.locationId || undefined, dynamicFields: board.dynamicFields }); return getDiscussionBoard(board.recordId); }
export const startTopic = (board: DiscussionBoardDetail, topicTitle: string, initialPostBody: string) => createDiscussionTopic(board.recordId, { topicTitle, initialPostBody });
export async function lockTopic(topic: DiscussionTopicDetail) { await updateDiscussionTopic(topic.recordId, { expectedUpdatedUtc: topic.updatedUtc, topicTitle: topic.topicTitle, isPinned: topic.isPinned, isLocked: true }); return getDiscussionTopic(topic.recordId); }
export async function reply(topic: DiscussionTopicDetail, postBody: string, parentDiscussionPostId?: string) { if (!topic.canReply) throw new Error("This topic is closed to replies."); await postDiscussionReply(topic.recordId, { postBody, parentDiscussionPostId }); return getDiscussionTopic(topic.recordId); }
export async function editPost(topic: DiscussionTopicDetail, post: DiscussionPost, postBody: string) { if (!post.canEdit) throw new Error("This post cannot be edited."); await updateDiscussionPost(post.recordId, { expectedUpdatedUtc: post.updatedUtc, postBody }); return getDiscussionTopic(topic.recordId); }
export async function toggleTopicFavorite(topic: DiscussionTopicDetail) { await setDiscussionFavorite(topic.recordId, !topic.isFavorite); return getDiscussionTopic(topic.recordId); }
Optional styling
.custom-boards { width: 100%; max-width: none; }
.custom-boards > ul, .custom-boards ol { list-style: none; margin: 1rem 0; padding: 0; }
.custom-boards button { text-align: left; }
.custom-boards > article { border: 1px solid var(--line, #d8dee8); margin-top: 1rem; padding: 1rem; }
.custom-boards article section { border-top: 1px solid var(--line, #d8dee8); padding: .75rem 0; }
Exact data path and current scale boundary
The route family is /api/modules/discussion-boards: list/create/update board; /{boardRecordId}/topics; /topics/{recordId}; /topics/{topicRecordId}/posts; /posts/{recordId}; and /records/{recordId}/favorite. Each maps to an explicit sp_DiscussionBoards_*Secured procedure and rechecks tenant, record, location, ownership, lock, archive, and moderation scope.
Important: board list pages are capped at 200, but the current board-detail contract returns all visible topics and the topic-detail contract returns all visible posts. Use this module for bounded communities today. Before using it for a forum with very large topics, add measured server pagination to those two detail contracts rather than fetching everything and trimming it in React.
Build a professional Discussion Boards dashboard
These six registry-backed presentation blocks let a designer turn the secured Discussion Boards API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/discussion-boards; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.
The ZIP contains this feature's fictional design composition at pages/first-class/discussion-boards-sample.json, its executable authenticated page at pages/first-class/discussion-boards-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.
| Payload kind | Runtime block | Useful Discussion Boards projection |
|---|---|---|
metric-set | core.metric-strip | Visible boards, open topics, unanswered work, and posts |
entity-list | core.entity-list | Active topics the user may open |
progress-list | core.progress-list | Distribution by topic state, board, or response |
series-chart | core.series-chart | New topics, replies, and resolved discussions |
data-grid | core.presentation-grid | One bounded topic page with counts and state |
timeline | core.timeline | Topic, reply, follow, moderation, answer, lock, and archive |
Copy/paste design preview: all six blocks
This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.
Copy the complete six-block page JSON
{
"blocks": [
{
"_id": "discussion-boards-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Discussion overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "boards",
"label": "Visible boards",
"value": 12,
"format": "number",
"tone": "neutral"
},
{
"key": "topics",
"label": "Open topics",
"value": 86,
"format": "number",
"tone": "primary"
},
{
"key": "unanswered",
"label": "Unanswered",
"value": 7,
"format": "number",
"tone": "warning"
},
{
"key": "posts",
"label": "Posts this week",
"value": 143,
"format": "number",
"tone": "success"
}
]
},
"children": []
},
{
"_id": "discussion-boards-recent",
"_type": "core.entity-list",
"props": {
"title": "Active topics",
"hasMore": true,
"items": [
{
"id": "discussion-boards-sample-1",
"recordId": "discussion-boards-record-1",
"primary": "Reducing repeat truck rolls",
"secondary": "Field service - 12 replies",
"status": {
"key": "open",
"label": "Open",
"tone": "primary"
},
"trailing": "2 new"
},
{
"id": "discussion-boards-sample-2",
"recordId": "discussion-boards-record-2",
"primary": "Reducing repeat truck rolls - Follow-up",
"secondary": "Field service - 12 replies - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "discussion-boards-sample-3",
"recordId": "discussion-boards-record-3",
"primary": "Reducing repeat truck rolls - West region",
"secondary": "Field service - 12 replies - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "discussion-boards-sample-4",
"recordId": "discussion-boards-record-4",
"primary": "Reducing repeat truck rolls - Customer response",
"secondary": "Field service - 12 replies - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "discussion-boards-sample-5",
"recordId": "discussion-boards-record-5",
"primary": "Reducing repeat truck rolls - Regional operations review with a deliberately long title",
"secondary": "Field service - 12 replies - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"trailing": "Review",
"tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
},
{
"id": "discussion-boards-sample-6",
"recordId": "discussion-boards-record-6",
"primary": "Reducing repeat truck rolls - Completed preview",
"secondary": "Field service - 12 replies - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "discussion-boards-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Topics by state",
"items": [
{
"key": "open",
"label": "Open",
"value": 86,
"maximum": 159,
"displayValue": "86",
"tone": "primary",
"status": {
"key": "open",
"label": "Open",
"tone": "primary"
}
},
{
"key": "answered",
"label": "Answered",
"value": 64,
"maximum": 159,
"displayValue": "64",
"tone": "success",
"status": {
"key": "answered",
"label": "Answered",
"tone": "success"
}
},
{
"key": "locked",
"label": "Locked",
"value": 9,
"maximum": 159,
"displayValue": "9",
"tone": "neutral",
"status": {
"key": "locked",
"label": "Locked",
"tone": "neutral"
}
}
]
},
"children": []
},
{
"_id": "discussion-boards-trend",
"_type": "core.series-chart",
"props": {
"title": "Discussion activity",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Topics",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Posts",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "discussion-boards-table",
"_type": "core.presentation-grid",
"props": {
"title": "Visible discussion topics",
"columns": [
{
"key": "topic",
"label": "Topic",
"type": "text",
"align": "left"
},
{
"key": "board",
"label": "Board",
"type": "text",
"align": "left"
},
{
"key": "replies",
"label": "Replies",
"type": "number",
"align": "right"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "updated",
"label": "Updated",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "discussion-boards-row-1",
"recordId": "discussion-boards-record-1",
"cells": {
"topic": "Reducing repeat truck rolls",
"board": "Field service",
"replies": 12,
"status": {
"key": "open",
"label": "Open",
"tone": "primary"
},
"updated": "2026-09-04T16:44:00Z"
}
},
{
"id": "discussion-boards-row-2",
"recordId": "discussion-boards-record-2",
"cells": {
"topic": "Reducing repeat truck rolls - Follow-up",
"board": "Field service",
"replies": 13,
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"updated": "2026-09-04T15:42:00Z"
}
},
{
"id": "discussion-boards-row-3",
"recordId": "discussion-boards-record-3",
"cells": {
"topic": "Reducing repeat truck rolls - West region",
"board": "Field service",
"replies": 14,
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"updated": "2026-09-04T12:18:00Z"
}
},
{
"id": "discussion-boards-row-4",
"recordId": "discussion-boards-record-4",
"cells": {
"topic": "Reducing repeat truck rolls - Customer response",
"board": "Field service",
"replies": 15,
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"updated": "2026-09-03T21:07:00Z"
}
},
{
"id": "discussion-boards-row-5",
"recordId": "discussion-boards-record-5",
"cells": {
"topic": "Reducing repeat truck rolls - Regional operations review with a deliberately long title",
"board": "Field service - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"replies": 16,
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"updated": "2026-09-03T16:31:00Z"
}
},
{
"id": "discussion-boards-row-6",
"recordId": "discussion-boards-record-6",
"cells": {
"topic": "Reducing repeat truck rolls - Completed preview",
"board": "Field service",
"replies": 17,
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"updated": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 12,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "discussion-boards-timeline",
"_type": "core.timeline",
"props": {
"title": "Discussion activity",
"hasMore": true,
"items": [
{
"id": "discussion-boards-event-1",
"recordId": "discussion-boards-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Reply posted",
"description": "A new field checklist suggestion was added.",
"actor": "Jordan Lee",
"tone": "primary"
},
{
"id": "discussion-boards-event-2",
"recordId": "discussion-boards-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Reply posted - Follow-up",
"description": "A new field checklist suggestion was added. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "discussion-boards-event-3",
"recordId": "discussion-boards-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Reply posted - West region",
"description": "A new field checklist suggestion was added. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "discussion-boards-event-4",
"recordId": "discussion-boards-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Reply posted - Customer response",
"description": "A new field checklist suggestion was added. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "discussion-boards-event-5",
"recordId": "discussion-boards-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Reply posted - Regional operations review with a deliberately long title",
"description": "A new field checklist suggestion was added. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "discussion-boards-event-6",
"recordId": "discussion-boards-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Reply posted - Completed preview",
"description": "A new field checklist suggestion was added. Closed after review with its related evidence retained.",
"actor": "Jordan Lee",
"tone": "success"
}
]
},
"children": []
}
]
}Copy/paste React composition
Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.
import {
EntityList,
MetricStrip,
PresentationGrid,
ProgressList,
SeriesChart,
Timeline,
} from "./InteractivePresentationComponents";
export interface DiscussionBoardsPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface DiscussionBoardsPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: DiscussionBoardsPresentationData | null;
readonly loading?: boolean;
readonly error?: boolean;
readonly styled?: boolean;
/** Record identity is navigation context; the detail API must authorize it again. */
readonly onOpenRecord?: (recordId: string) => void;
}
export function DiscussionBoardsPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: DiscussionBoardsPresentationProps) {
if (error) return <p role="alert">The Discussion Boards presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Discussion Boards presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Discussion Boards</p>
<h1>Discussion overview</h1>
<p>Boards, topics, participation, unanswered work, and recent posts.</p>
</header>
<MetricStrip data={data.metrics} styled={styled} />
<div className={styled ? "bwhq-api-example__split" : undefined}>
<ProgressList data={data.byStatus} styled={styled} />
<EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
</div>
<SeriesChart data={data.trend} styled={styled} />
<PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
<Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
</main>
);
}
Copy the live, authenticated module page
This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/discussion-boards, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.
Copy the executable live page
{
"blocks": [
{
"_id": "discussion-boards-live",
"_type": "module.discussion-boards",
"props": {},
"children": []
}
]
}The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.
Choose Chat Rooms for fast conversation and Discussion Boards for durable topics. Avoid enabling both for the same job without a clear information lifecycle.
A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.
Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.