Comments
@plumix/plugin-comments is discussion on your content: a thread a theme renders, a form a visitor writes into, a trust policy that decides what appears at once, and a moderation queue in the admin for everything else. The form posts as a plain <form method="post">, so it works with JavaScript switched off and is upgraded in place where there is some.
Overview
Section titled “Overview”The plugin exports a factory named comments, so you call it. It owns a database table, which makes installing it a package install, one array entry and a migration.
Commenting is off for every entry type until one opts in. A type opts in either by being named in comments({ entryTypes }) — the way to enable a type whose registration you do not own — or by self-declaring supports: ["comments"] in its own registration. Neither is special: entryTypes is the add_post_type_support shape, and a type that declares its own support needs nothing here at all.
What it registers:
- A
commentstemplate dep, so a theme can declarecomments: ["current"]on a template and render the approved thread for the entry it is displaying. It reads the entry URL’s?comments=parameter, so older comments have pages of their own. POST /_plumix/comments/submit, the public endpoint every comment arrives at.GET /_plumix/comments/list, which pages older roots for a thread that has more thanrootsPerPageof them.- A REST resource at
/{collection}/{entry}/comments, behind the site’sapi.enabledswitch. An entry that isn’t a published one open to anonymous visitors, or isn’t of the named collection’s type, is a 404; one whose type has commenting off is an empty page. - A moderation queue in the admin, gated on the
comment:moderatecapability the plugin mints for the editor role. - The
comment.moderatefilter and thecomment.createdevent, which is where a spam or notification plugin attaches. - The lifecycle events a moderator’s writes emit:
comment.approved(also on restore),comment.spam,comment.trashed, andcomment.deletedwhen a moderator removes a comment, outright or as a tombstone. Each passes{ comment }, the comment row, andcomment.deletedpasses it as it stood before the removal.
The package root exports the events and the filter as one namespace, comment, so another plugin subscribes with ctx.events.on(comment.approved, …) or ctx.filters.add(comment.moderate, …) with no declare module.
Two surfaces render the form. PlumixCommentForm from @plumix/plugin-comments/theme is the plugin’s own markup, and usePlumixCommentForm from @plumix/plugin-comments/hooks is the same submission with none of the markup, for a theme writing its own controls. The thread has the same pair: PlumixCommentThread from @plumix/plugin-comments/theme, and usePlumixCommentThread from @plumix/plugin-comments/hooks.
Quickstart
Section titled “Quickstart”Adding comments to a recipe site whose recipe type does not declare support for them.
pnpm add @plumix/plugin-commentsnpm install @plumix/plugin-commentsyarn add @plumix/plugin-commentsbun add @plumix/plugin-comments-
Put it in the
pluginsarray, naming the types it applies to.import { comments } from "@plumix/plugin-comments";export default plumix({// …plugins: [recipes, comments({ entryTypes: ["recipe"] })],theme,}); -
Apply the migrations. The plugin ships its own migration history, and
plumix migrateapplies it once the descriptor is in the array.plumix devdoes the same on start, andplumix deployand the built Node and Bun servers apply it in production.Terminal window pnpm plumix migrate -
Render the thread and the form on the template that displays a recipe.
import { defineTemplate } from "plumix/theme";import {PlumixCommentForm,PlumixCommentThread,} from "@plumix/plugin-comments/theme";export const recipe = defineTemplate({single: {comments: ["current"],render: ({ entry, comments }) => (<article><h1>{entry.title}</h1><PlumixCommentThread thread={comments?.current ?? null} /><PlumixCommentForm entryId={entry.id} /></article>),},}); -
Restart the dev server.
Terminal window pnpm devRecipes now take comments, and the queue is in the admin under Entries.
The form works before any JavaScript does
Section titled “The form works before any JavaScript does”PlumixCommentForm renders a real <form method="post"> pointed at the submit endpoint. Nothing about it needs a script: a visitor with JavaScript switched off, on a connection that dropped the bundle, or reading through a browser that never ran one, writes a comment and it is stored.
That is possible because the submit route is registered formPost. A browser cannot set a custom header on an ordinary form submit, so core’s CSRF gate — which looks for X-Plumix-Request — would refuse the post before the handler ever ran. formPost drops that requirement for the POST, and the Origin check becomes the whole control: an exempt request has to carry an Origin or Referer matching the site, where an ordinary one is only refused for contradicting one. A cross-origin form post is still refused.
The endpoint reads a urlencoded body, which is what a <form> with no enctype sends and the plugin’s own markup never sets one of. A hand-written form that sets enctype="multipart/form-data" is not read as a form post — multipart would mean file uploads, which this plugin deliberately does not accept.
An accepted comment is answered with a 303 back to the page the form was on, resolved from the form’s own hidden returnTo field first and the request’s Referer second. A relative returnTo resolves against the request’s own URL, so /posts/hello#comments is a path on your site rather than a value that gets dropped. Both candidates are the visitor’s to set, so both are held to an origin the site answers on and refused the endpoint’s own path: the response can be turned into neither an open redirect nor a loop.
A refused comment is answered with the form back — the same component, carrying what the visitor typed and the refusal against the field that produced it. That is the whole reason the plugin renders markup at all. A plugin that owned no form could only redirect or serve a bare page, and either way a rate-limited commenter loses what they wrote.
Where JavaScript does run, an island upgrades that same markup in place: it posts as JSON with the CSRF header, renders refusals without leaving the page, and can say a comment was held for review — which the redirect cannot. Putting the outcome in the URL instead would fork the page’s edge-cache entry once per outcome.
Older comments work without JavaScript too
Section titled “Older comments work without JavaScript too”The comments dep renders the newest rootsPerPage roots. Older roots live at the entry’s own URL with a ?comments=<cursor> parameter, /posts/hello?comments=1780531200_4, and the dep renders that page on the server like any other. The cursor names the comment the page starts below, so a link keeps pointing at the same comments as new ones arrive; only the newest page’s link to the next one moves.
PlumixCommentThread renders a page of the thread, each comment with its avatar, author, date, body and nested replies. Below it are two plain links: Older comments while there is an older page, and Newest comments on any page but the newest. With JavaScript off, or before the page has hydrated, Older comments navigates to the next page. Once an island hydrates, the same link loads the older roots into the page through usePlumixCommentThread and the URL stays put.
<PlumixCommentThread thread={comments?.current ?? null} renderBody={(comment) => ( <div className="prose" dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} /> )}/>renderBody replaces the default body markup on the server-rendered page. A function cannot travel to the browser, so the comments the island loads inline use the default body; style them through the plumix-comment-* classes the markup carries.
A theme rendering its own thread gets the same links from the dep’s result, ResolvedThreadPage:
| Field | What it holds |
|---|---|
olderHref |
The next older page’s URL, with the site’s base path and the rest of the query kept. null on the oldest page. |
newestHref |
The entry’s URL without ?comments=, or null on the newest page. |
A ?comments= value that is not a cursor answers a 301 to the entry without it, so a junk value never becomes a stored copy of the newest page. Every page keeps core’s canonical, which drops the query string, so it canonicalises to the entry, and the plugin adds no robots tag. The CDN stores each cursor page once under the entry’s thread, so the purges below cover it too.
A theme that writes its own controls
Section titled “A theme that writes its own controls”loadThread and a hand-written form stay fully supported, and nothing about this slice changes them. Where the theme wants its own controls but not its own submission logic, usePlumixCommentForm hands back everything the form needs and nothing about how it looks.
"use client";
import { usePlumixCommentForm } from "@plumix/plugin-comments/hooks";
export function ReplyBox({ entryId }: { entryId: number }) { const form = usePlumixCommentForm({ entryId }); if (form.status !== null) { return <p>{form.status === "approved" ? "Posted." : "Sent for review."}</p>; } return ( <form onSubmit={(event) => { event.preventDefault(); const data = new FormData(event.currentTarget); void form.submit({ name: String(data.get("name")), email: String(data.get("email")), body: String(data.get("body")), }); }} > <input name="name" required /> <input name="email" type="email" required /> {form.errorFor("email") ? <p>{form.errorFor("email")}</p> : null} <textarea name="body" required /> <button type="submit" disabled={form.submitting}> Post comment </button> </form> );}It posts to the same endpoint the rendered form posts to, so a comment sent from a theme’s own controls meets the rate limit, the trust policy and the comment.moderate filter exactly as one sent from the plugin’s markup does. The honeypot is the one thing it does not meet — the trap is a field in markup this hook does not render, which is the trade of writing the markup yourself.
loadThread comes from @plumix/plugin-comments/server, the entry that loads a thread without pulling in admin code. It takes the request context, the entry id, and the page to load, { maxDepth, rootsPerPage, cursor }. It resolves to a ResolvedThread, which the comments template dep extends with the page links above. comments holds the page’s roots newest first, each with its replies, and hasMore and nextCursor point at the next page over /list. The same entry exports the ResolvedThreadPage, ResolvedThread and ResolvedComment types for typing a theme’s own thread component. A comment carries the author’s name and avatar URL and its body already rendered to HTML, never the author’s email.
"use client" belongs on the theme’s own component, which is the thing that hydrates. It must never go on a module that re-exports the hook: the directive marks an island, every export of a module carrying one is replaced by a server shim, and a hook shimmed into a component returns a React element rather than the state the caller asked for.
What decides whether a comment appears
Section titled “What decides whether a comment appears”mode is the trust policy, and it decides the baseline before any filter runs.
mode |
A comment is |
|---|---|
"first_time" |
Held the first time an email comments; approved once that email has one approved comment. The default. |
"all" |
Always held for review. |
"none" |
Always approved. |
A comment from a signed-in author takes a fast path to approved whatever the mode — subject to the divergence above, which is the one place that fast path is not taken.
The comment.moderate filter runs on that baseline and may only push a comment toward the restrictive end, so two detectors compose without caring which ran first. A handler returning anything that is not a known status is ignored rather than persisted. A handler receives the status so far and the comment being submitted, { entryId, authorName, authorEmail, bodyMd, ipHash, isAuthenticated }:
import { definePlugin } from "plumix/plugin";
import { comment } from "@plumix/plugin-comments";
export const casinoGuard = definePlugin("casino-guard", { setup: (ctx) => { ctx.filters.add(comment.moderate, (status, candidate) => candidate.bodyMd.includes("casino") ? "spam" : status, ); },});CommentModerationCandidate, from @plumix/plugin-comments/server, names that second argument for a handler written apart from the call that subscribes it.
A comment purges the cached copy of its entry’s page from the CDN only when visitors would see the change: it is approved or restored to approved, an approved comment is spammed, trashed or deleted, or a new comment arrives approved. A comment moving between held, spam and trash purges nothing, since the page shows none of them. Only the pages that render the entry’s thread are purged, its ?comments= pages included; an archive that shows the entry as a card stays stored.
Two spam defences sit below it. The honeypot is a field a person never sees and a bot fills; a comment that trips it is answered exactly as a real one is — telling a bot it was caught only teaches it to stop filling the trap — and no row is written. The rate limit is a sliding window over a salted hash of the visitor’s address. Off Cloudflare that address is client-spoofable, so the limiter is best-effort there and edge or WAF rules are the real flood defence.
Options
Section titled “Options”Every option has a default, and comments() on its own is a complete registration that enables nothing.
| Option | Default | What it does |
|---|---|---|
entryTypes |
[] |
Types to enable commenting on, beyond those declaring their own support. |
mode |
"first_time" |
The trust policy above. |
maxDepth |
3 |
Reply nesting depth. A deeper reply is clamped rather than refused. |
rootsPerPage |
20 |
Root comments per page; older roots get ?comments= pages. |
requireEmail |
true |
Whether an author email is required. |
closeAfterDays |
null |
Refuse comments on entries published longer ago than this. |
rateLimit |
{ max: 5, windowMin: 10 } |
The sliding window, per hashed source. |
notifyEmail |
null |
Email a moderator when a comment is held. Needs a configured mailer. |
notifyEmail sends the commentAwaitingModeration mail through the top-level mailer in the site config: the comment, the entry it was left on and a link to the moderation queue, in the moderator’s own locale when the address belongs to a user. A site or theme can override its subject, text or HTML by that name, as Mail describes. With no mailer configured, the plugin ignores the address and sends no email.
Related
Section titled “Related”- Plugins overview — installing a published plugin, and the version tracks that decide which number you pin.
- Templates — where the
commentstemplate dep is declared, and where the form component goes. - Template data — the shape a
commentsdep hands the template it is declared on.
Next steps
Section titled “Next steps”- Declare the
commentstemplate dep on every template that should show a thread — Templates. - Attach a spam detector to
comment.moderate, which sees every comment the other checks accepted. - Set
notifyEmailonce a mailer is configured, so a held comment reaches a person rather than only the queue.