Self-hosted comment threads for any page: a Payload CMS admin for content, a TanStack Start site with docs and blog, and a Bun/Drizzle comments service with Better Auth, a moderation dashboard, publisher API keys, and a server plugin system. Payload owns /admin; comments, sites, votes, API keys and auth sessions live in a separate business database.
cp .env.example .env # PAYLOAD_SECRET, BETTER_AUTH_SECRET and both database URLs
bun run stack:up # migrations + app + plugin worker (bring your own Postgres)Open http://localhost:3000/create-admin-user to create the first business admin, then sign in at /moderation to register a site and its allowed origins. Open /admin to create the first Payload admin user.
compose.yaml bundles no database: point DATABASE_URL and BUSINESS_DATABASE_URL at your own Postgres (append ?sslmode=require when it uses TLS), then create both databases. The stack runs a one-shot migration service, the app on :3000, and the plugin outbox worker. Turnstile defaults to Cloudflare's public testing keys, which work on any hostname including localhost, but the app refuses them while NODE_ENV=production (the Compose default). Replace both keys in .env for production, or set NODE_ENV=development for a local run. See Self-hosting for production settings, backups and upgrades.
bun install
cp .env.example .env # PAYLOAD_SECRET, BETTER_AUTH_SECRET and both database URLs
bun run db:migrate
bun run db:business:migrate
bun run generate:types
bun run generate:importmap
bun run generate-routes
bun run dev # http://localhost:3000Point DATABASE_URL and BUSINESS_DATABASE_URL at your own Postgres and create both databases before migrating; the repository bundles none. From inside Compose, a database on the Docker host is host.docker.internal. bun run stack:up runs the whole stack. For Google sign-in, register http://localhost:3000/api/auth/callback/google as the OAuth redirect URI. Public email/password registration stays disabled; the first account creates the instance admin, and later users sign in with Google or a configured provider.
Existing databases are not promoted during migration. To recover a failed first setup or grant the first instance admin manually:
bun run db:business:grant-admin -- [email protected]Auth providers are configured in src/server/auth/auth.config.ts (better-auth, oidc-jwt, header, or a chain such as ['better-auth', 'oidc-jwt']). AUTH_PROVIDERS overrides the file per environment; endpoint identity and secrets stay in .env. See Identity and Auth providers.
GET|POST /api/comments takes a site plus the absolute page url; register the page origin as an allowed origin for that site first. Every post carries a Turnstile token when verification is enabled; guests also provide a display name. Responses include vote score, the signed-in viewer's viewerVote, and the author's site role as authorRole (admin, member for any other signed-in account, or null for a guest). Deleted comments are returned redacted (status deleted, empty body and author) so their replies keep their place; hidden comments are left out.
PUT /api/comments/votecasts or switches a vote;DELETE /api/comments/vote?site=…&commentId=…clears it. Voting needs a visitor session, so guests and keys cannot vote. A site can switch voting off (votesEnabled, the dashboard's Allow votes); then votes are refused with403 FEATURE_DISABLEDand the votes already cast are kept. A gateway-only site refuses votes from browsers.- Signed-in authors may edit their own comments at any time via
PATCH /api/comments/:id. Guests receive a one-timeeditKeyin the create response and may use it within the guest edit window (comment-policy.config.ts, default 10 minutes;0= unlimited). Expired guest edits fail with 403EDIT_WINDOW_EXPIRED. - Delete via
DELETE /api/comments/:id?site=…: a signed-in author deletes with the session; a guest sends the edit key in thex-lc-edit-keyheader, at any time (no edit window). A deleted comment is not removed from the thread: it reads as[This comment was deleted]with no body, author or votes, and its replies stay nested under it. An admin can restore it. - Page threads are normally created by their first comment. A publisher can register them with
POST /api/admin/pages({ site, url, pageKey?, title? }, idempotent). ApageKeyfollows the thread when its URL changes. The URL must use one of the site's allowed origins, otherwise400 PAGE_ORIGIN_NOT_ALLOWED. - Callers are admins of the site (its site admin, or the instance admin), or a site key holding
pages:write;pages:readlists a site's threads. Instance keys keepsites:*. A member is refused. DELETE /api/admin/pages/{pageId}orDELETE /api/admin/pages?pageKey=removes a page permanently, with its comments, replies and votes. It needs apages:deletekey (separate frompages:write, so write-only keys cannot delete) or a site admin session. The site's blocks, verifications and memberships stay; the audit log keeps the URL.- A site can set its own comment length limit (1 to 10,000 characters) with
PATCH /api/admin/sites/{siteKey}; the default is 10,000. - A site's
access(open|login|private) sets who may read and post, andgatewayOnlyrefuses callers that hold no site-bound key. Keys holdingcomments:read/comments:writelet a host proxy load and post comments for its visitors. Seecontent/docs/auth.mdx, "Who can comment". GET /api/comments/settings?site=publishes the public policy a widget needs (access level, length limit, guest edit window, public Turnstile key). It holds no secrets and is cached for a minute.
Run bun run plugins:work as a separate process to deliver plugin events unless the Compose worker service is running.
The dashboard is for admins: the instance admin, or a site admin for its own sites. Members and commenters see an "Admins only" page (see Roles and the dashboard).
/moderation → Pages → View comments renders a thread with @lst97-oss/last-comments-widgets (/react entry plus its scoped stylesheet): markdown bodies, hidden/deleted badges, nested replies, pinned comments first, and the moderation row. Only rendering and the composer come from the package; every action still posts to this repo's /api/moderation.
The admin composer posts through { target: 'comment-create', site, pageId, parentId?, bodyMarkdown }. It skips the public origin, guest-name and Turnstile gates because the session is the verification, while remaining site-scoped: unknown pageId → 404 PAGE_NOT_FOUND, hidden/deleted parent → 404 PARENT_NOT_FOUND, locked page → 409 PAGE_LOCKED. Bodies are capped at 10,000 characters (COMMENT_BODY_LIMIT in src/lib/comment-schema.ts), counted before trimming and passed to the widget as bodyLimit.
Plugins are trusted server-side TypeScript modules, statically imported in src/server/plugins/config.ts. Installing a package does not activate it, and the host never downloads code at runtime.
import { resolveClientIp } from '@/server/plugins/rate-limit/client-ip'
import { getRateLimitPluginSettings } from '@/server/plugins/rate-limit/rate-limit.config'
import { createRateLimitPlugin } from '@/server/plugins/rate-limit/rate-limit.plugin'
import { turnstilePlugin } from '@/server/plugins/turnstile/turnstile.plugin'
import { discordPlugin } from '@example/last-comments-plugin-discord'
export const installedCommentPlugins = [
turnstilePlugin,
createRateLimitPlugin((request) =>
resolveClientIp(request, { trustedCidrs: getRateLimitPluginSettings().trustedProxyCidrs }),
),
discordPlugin,
] satisfies readonly CommentPlugin[]Capabilities: validateComment (ordered, five-second timeout), beforeOperation (public create/edit/delete/vote boundaries), screenComment (advisory hide-for-review), events + handleEvent (transactional outbox delivered by plugins:work), and migrations (run by db:business:migrate only for installed plugins). Handlers must be idempotent and honor the abort signal. Plugin DDL never runs at app startup.
The rate-limit plugin is enabled by default and allows creates 5/10 minutes, edits and deletes 20/10 minutes, and votes 60/minute per site and requester. Signed-in requests use the Better Auth user ID; guests use a keyed HMAC of the resolved client IP, keyed by a key derived from BETTER_AUTH_SECRET; the raw IP is not stored. The client IP is the request's socket peer, except when the request came from a trusted ingress — then that ingress's client IP header is used (CF-Connecting-IP behind Cloudflare, otherwise x-real-ip). Trusted ingress is loopback and private networks by default; set RATE_LIMIT_TRUSTED_PROXY_CIDR for anything else, so a direct client cannot rotate the header to dodge the limit. DB errors fail closed with 503; over-limit responses include Retry-After.
To remove it, delete it from installedCommentPlugins and deploy after running the normal business migrations. Its table and data remain; bun run db:business:plugins:uninstall rate-limit explicitly drops them.
Turnstile is enabled by default and verifies comment tokens server-side. Set TURNSTILE_SECRET_KEY in the environment and the public site key in TURNSTILE_SITE_KEY (or turnstile.config.ts, which wins). The consuming site renders the widget and sends the token as verificationToken; every commenter is verified, signed-in ones included. The token's hostname must match one of the site's registered origins, so register every embedding origin. Missing/invalid tokens fail with 403, unavailable verification fails closed with 503.
Local development can use Cloudflare's testing keys:
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AAThe filter plugin is installed and on by default (enabled: true in filter.config.ts). A hit holds the comment for review as hidden instead of rejecting the write. It combines a local bad-words engine with the OpenRouter LLM decider (perplexity/pplx-decider-v1.2, Decisions API); both engines are enabled by default, OPENROUTER_API_KEY is required for the LLM engine, and failures resolve through failOpen. Screening sends the author name, comment text, site host and image thumbnails to OpenRouter, so turn engines.llm off where that data must stay on your server.
The notifications plugin notifies the site's admins in their dashboard bell about new, held, hidden and deleted comments, and about page reactions (off by default). Events are switched on with NOTIFICATIONS_EVENTS. It needs NOTIFICATIONS_URL, NOTIFICATIONS_APP_KEY and a lnk_… key, and the plugin worker (bun run plugins:work) must run. See src/server/plugins/notifications/README.md.
| Script | Purpose |
|---|---|
dev |
Vite dev server on :3000 |
build |
Generate routes, Scalar, admin CSS, types and import map, then vite build |
start |
Run the Nitro output for the active preset |
generate:types / generate:importmap |
Regenerate payload-types.ts / src/payload-import-map.ts |
generate-routes |
Regenerate src/routeTree.gen.ts |
generate:payload-admin-css |
Regenerate src/styles/payload-admin-generated.css (also runs in build) |
db:migrate |
Apply Payload migrations (src/migrations/) |
db:business:migrate |
Apply core business migrations and installed-plugin migrations |
db:business:plugins:uninstall <id> |
Drop a supported plugin's data and migration history |
db:business:grant-admin |
Grant instance admin to an existing business account |
db:business:reset -- --confirm |
Local only: drop the business schemas and re-apply the migrations from empty |
db:seed:load / db:bench:comments |
Local only: load a 275k-comment fixture, then time the comment queries |
plugins:work |
Deliver plugin outbox events |
stack:up / stack:down |
Build/start or stop the full Compose stack |
smoke |
Boot-contract check against a running server |
typecheck |
bun check --no-script (Bun 1.4.4+ native checker) |
format / format:check |
bun fmt / bun fmt --check (config in bunfig.toml) |
lint / lint:fix |
Biome lint + import assist (formatter disabled) |
check |
format:check → lint → typecheck (what CI runs) |
compose.yaml migrations, app and plugin worker (external Postgres)
Dockerfile multi-stage Bun image (prod deps, generated build, ops scripts)
payload.config.ts Payload collections: users, media, posts
src/routes/ site, blog, docs, Payload admin shell, comments/auth/moderation APIs
src/server/ business kernel: modules, db, auth, plugins, health, security
src/lib/payload/ Payload admin bridge for TanStack Start
src/migrations/ Payload migrations
drizzle/ business + plugin migrations
content/docs/ product documentation (Fumadocs MDX)
tests/ unit and integration suites
vendor/ vendored RSC-independent Payload Vite plugins
scripts/ smoke, migrations, plugin worker, assets, docker init
.github/workflows/ci.yml format + lint + typecheck + migrations + tests + build/smoke + Docker Compose smoke
.bun-version single Bun version source (Dockerfile ARG + CI)
TanStack add-ons can be installed with bunx @tanstack/cli add <addon>, but review every touched file: this repo depends on the Payload/Nitro wiring (vite.config.ts plugin order, withPayloadRoot, payloadParseSearch/payloadStringifySearch, src/plugins/payload-shutdown.ts). Hand-built routes are the common path:
# add src/routes/<name>.tsx
bun run generate-routes
bun run typecheck && bun test
bun run devUse @/ imports only. Server work belongs in createServerFn handlers under src/lib/. Query already has its provider via setupRouterSsrQueryIntegration in src/router.tsx; never add an explicit QueryClientProvider.
Payload currently owns users, media and posts. For a new collection:
-
Add it to
payload.config.tswith fields,admin.useAsTitleandaccess. -
Create a migration — never dev auto-push (
push: false):bun run payload migrate:create # split value/type imports per AGENTS.md trap bun run db:migrate bun run generate:types -
Verify in
/adminand extend the integration tests.
Business tables that Payload never owns belong in src/server/modules/<name>/ on getBusinessSql() / getBusinessDb(), with migrations under drizzle/ (see src/server/README.md).
Payload requires PAYLOAD_SECRET, DATABASE_URL and PAYLOAD_PUBLIC_SERVER_URL (loopback is rejected when NODE_ENV=production). Comments/auth use BUSINESS_DATABASE_URL and the BETTER_AUTH_* / Google settings. .env.example is the complete local reference.
- Vercel: default Nitro preset. Run
db:migrateanddb:business:migratebefore rollout and runplugins:workas a separate worker. - Docker/Compose:
bun run stack:up. The image includes the admin-grant and plugin worker entrypoints plus the business migration script. SetNODE_ENV=production, a public HTTPSPAYLOAD_PUBLIC_SERVER_URL/BETTER_AUTH_URL, real secrets and real Turnstile keys, and pointDATABASE_URL/BUSINESS_DATABASE_URLat your Postgres (?sslmode=...when it uses TLS). Use a reverse proxy that overwritesx-real-ip(the rate limit trusts it only from a trusted ingress — seeRATE_LIMIT_TRUSTED_PROXY_CIDR), and back up your Postgres. See Self-hosting.