Documentation

Comments

Add a comment section under each VitNode Content Engine record with a comment content type, the thread option and the shared ContentComments section from @vitnode/core. You write a small typed adapter, not a list and a form.

Comments are records of their own, in a comment content type that points at the article it answers. The thread option gives that type a route that returns an article's comments a page at a time, and the server checks where every comment may go. On the page, useContentComments from @vitnode/core/tanstack/comments connects that route to your plugin with a few typed calls. ContentComments then renders the whole section: the list, the composer, loading, empty and error states, and a sign-in link for guests.

This page adds flat comments under each article. Comment actions adds editing, deleting, hiding and reports to the same section, and Replies nests answers.

Before you begin

You need:

  • The article type with its public API and public page, from the beginner path up to Content page. The article itself needs no author and no publicWrite. Staff can keep writing every article in the AdminCP. Members comment through the comment type's own author field and publicWrite, which you add below.
  • Member accounts. Signing in is part of every VitNode app; it does not depend on members writing articles.

There's nothing else to install. The section, its forms and its messages ship with @vitnode/core.

Define the comment type

Create src/content/comment.ts. This is the complete file:

plugins/newsroom/src/content/comment.ts
import { defineContentType, field } from "@vitnode/core/content";

import { articleContentType } from "@/content/article";

export const commentContentType = defineContentType({
  id: "newsroom.comment",
  tableName: "newsroom_comments",

  fields: {
    slug: field.slug({ generated: true }),
    body: field.richText({ required: true }),
    author: field.user(),
    article: field.relation({
      target: () => articleContentType,
      required: true,
      onDelete: "cascade",
    }),
    parent: field.relation({
      self: true,
      nullable: true,
      onDelete: "set null",
    }),
  },

  publication: true,

  publicApi: {
    path: "article-comments",
    fields: [
      "id",
      "slug",
      "body",
      "article",
      "parent",
      "publishedAt",
      "createdAt",
      "updatedAt",
    ],
    people: ["author"],
  },

  publicWrite: {
    ownership: { field: "author" },
    create: { fields: ["body", "article", "parent"], status: "published" },
  },

  thread: { parent: "parent", root: "article", maxDepth: 1 },

  admin: { path: "newsroom/comments", titleField: "slug" },
});
  • article is the comment's root, the record it belongs to. It must be a required relation to another content type. onDelete: "cascade" removes the comments with their article.
  • parent is the comment being answered, or null. thread requires it even for flat comments; maxDepth: 1 keeps every comment top level until Replies.
  • slug with generated: true gets a random token, because a comment has no title. The public API needs exactly one slug.
  • publicApi.path is where the comment routes live: /api/newsroom/content/article-comments/.... Pick a path no other plugin on the site uses. A permalink claims it site-wide later, and the Blog plugin already owns comments.
  • createdAt and updatedAt in the public fields let the section show when a comment was written and mark it edited.
  • publicWrite lets members post body under an article, and create.status: "published" makes a new comment public at once. A comment's place is fixed when it is written, so parent and article never become editable.

Create the model and register it

Create src/database/comments.ts. This is the complete file; references points the article relation at the articles table:

plugins/newsroom/src/database/comments.ts
import { createContentModel } from "@vitnode/core/content/server";

import { commentContentType } from "@/content/comment";

import { newsroom_articles } from "./articles";

export const commentContent = createContentModel(commentContentType, {
  references: { article: () => newsroom_articles.id },
});

export const newsroom_comments = commentContent.table;

Add commentContent next to articleContent in the public module in src/config.api.ts. This is an excerpt:

plugins/newsroom/src/config.api.ts
import { commentContent } from "@/database/comments"; 

buildContentPublicModule({
  contentTypes: [articleContent, commentContent], 
}),

Then give staff the comment list. Add commentContent to buildContentAdminModule in src/api/modules/admin/admin.module.ts, and its link to src/admin/nav.tsx. These are excerpts:

plugins/newsroom/src/api/modules/admin/admin.module.ts
import { commentContent } from "@/database/comments"; 

buildContentAdminModule({ contentTypes: [articleContent, commentContent] }),
plugins/newsroom/src/admin/nav.tsx
import { MessageSquareIcon, NewspaperIcon } from "lucide-react";

import { commentContentType } from "@/content/comment"; 

  contentTypes: [
    { definition: articleContentType, icon: <NewspaperIcon /> },
    { definition: commentContentType, icon: <MessageSquareIcon /> }, 
  ],

Label, build and migrate

Add the comment type's labels to src/locales/en.json, next to article under newsroom.content, and its permission labels at the top level:

plugins/newsroom/src/locales/en.json
{
  "newsroom": {
    "content": {
      "comment": {
        "label": "{count, plural, one {Comment} other {Comments}}",
        "title": "Comments",
        "desc": "What readers say under the articles.",
        "fields": {
          "slug": "Link",
          "body": "Comment",
          "author": "Author",
          "article": "Article",
          "parent": "Answers"
        }
      }
    }
  },
  "newsroom:comment": "Comments",
  "newsroom:comment:can_view": "View comments",
  "newsroom:comment:can_create": "Create comments",
  "newsroom:comment:can_edit": "Edit comments",
  "newsroom:comment:can_delete": "Delete comments",
  "newsroom:comment:can_publish": "Publish and unpublish comments",
  "newsroom:comment:can_hide": "Hide and unhide comments"
}

Then build and migrate from the workspace root. db:migrate reads your plugin's built files, so build first:

Build and migrate
bun run build:plugins
bun run db:migrate

The migration creates newsroom_comments with a foreign key to newsroom_articles. The build also regenerates the plugin's route types, which the next step relies on. The thread route now answers:

curl "http://localhost:3000/api/newsroom/content/article-comments/thread?root=1"
200
{ "items": [], "pageInfo": { "endCursor": null, "hasNextPage": false, "totalCount": 0 }, "replyCount": 0, "truncated": false }

Connect the comment routes

Create src/pages/article-comments.tsx. This is the complete file:

plugins/newsroom/src/pages/article-comments.tsx
import {
  ContentComments,
  contentCommentsQueryOptions,
  useContentComments,
} from "@vitnode/core/tanstack/comments";
import { fetcher } from "@vitnode/core/tanstack/fetcher";

import { manifest } from "@/manifest.gen";

/** The thread under one article, shared by the page's loader and its section. */
const articleCommentsThread = (articleId: number) => ({
  queryKey: [manifest.id, "comments", articleId],
  thread: async ({ after, first }: { after: null | string; first: number }) =>
    await fetcher({
      plugin: manifest.id,
      method: "get",
      module: "content/article-comments",
      path: "/thread",
      args: {
        query: { root: String(articleId), first, ...(after ? { after } : {}) },
      },
    }),
});

/** Warms the first page of comments in the article page's loader. */
export const articleCommentsQueryOptions = (articleId: number) =>
  contentCommentsQueryOptions(articleCommentsThread(articleId));

export const ArticleComments = ({ articleId }: { articleId: number }) => {
  const comments = useContentComments({
    ...articleCommentsThread(articleId),
    create: async ({ body, parentId }) =>
      await fetcher({
        plugin: manifest.id,
        method: "post",
        module: "content/article-comments",
        path: "/",
        args: {
          body: { values: { article: articleId, body, parent: parentId } },
        },
      }),
  });

  return <ContentComments className="border-t pt-8" comments={comments} />;
};
  • Each callback is one inline fetcher call and returns its response unchanged. The module, path, arguments and answer stay typed from your routes, so a typo is a compile error.
  • queryKey names this article's thread in the TanStack Query cache. Every write refreshes it, and the same key loads the first page in the loader below.
  • thread gets the cursor and page size from the section and reads one page. Load more comments asks for the next one.
  • create gets the text and parentId, which is always null for flat comments. Leave create out, and the section shows the conversation without a composer.
  • ContentComments renders everything else. It loads its own messages, posts through the write form helpers, keeps the text when the server refuses it, and tells published, draft and pending comments apart.

Show it below the article

Edit src/pages/article-page.tsx. The loader warms the first page of comments, which needs the query client from defineRoute. This is an excerpt:

plugins/newsroom/src/pages/article-page.tsx
import { defineRoute } from "@vitnode/core/tanstack/plugin-routes";

import { ArticleComments, articleCommentsQueryOptions } from "./article-comments"; 

export const route = defineRoute({
  load: async ({ context, params }) => {
    const page = await loadArticle(params.slug);
    // The first page of comments arrives with the article's HTML. A failure
    // leaves the section to show its own error instead of breaking the page.
    await context.queryClient
      .infiniteQuery(articleCommentsQueryOptions(page.item.id))
      .catch(() => null);

    return page;
  },

  head: ({ loaderData }) =>
    contentDeliveryPageHead(loaderData?.metadata, {
      title: loaderData?.item.title,
    }),
});
  • If the route still uses definePluginRoute from Content page, switch to defineRoute as shown; definePluginRoute has no query client.
  • If Embedded list already warms Latest articles here, run both with Promise.all, so neither waits for the other.

Render the section at the end of <article>, after the body:

plugins/newsroom/src/pages/article-page.tsx
      <RichTextContent
        className="leading-relaxed text-pretty"
        content={item.body}
      />

      <ArticleComments articleId={item.id} /> {}
    </article>

Finally, load the section's messages with the page, so they arrive with its HTML instead of right after. In src/routes.ts:

plugins/newsroom/src/routes.ts
  page("/news/:slug", {
    component: lazy(() => import("./pages/article-page")),
    messages: ["newsroom.articles", "core.comments"], 
  }),

Check the result

Open /news/welcome-to-the-newsroom, the article staff created in First content type and published in Drafts & publishing:

  1. Signed out, the section shows No comments yet and Sign in to comment. The link brings you back to the article after signing in.
  2. Sign in as a member, click Write a comment…, write something and click Post comment. Comment posted appears, the comment shows with your name, the heading reads 1 comment, and the editor is empty again.
  3. Reload the page. The comment is still there, which proves it came from the server and not from the form's memory.
  4. Sign in as another member and comment too. The heading reads 2 comments.

To try a draft, set create.status to "draft" in src/content/comment.ts, rebuild and post again. Comment saved as a draft appears, and the list and the count stay as they were, because a draft is not public. Set it back to "published" afterwards.

A comment section under a Newsroom article titled 2 comments, with comments by Ada Lovelace and Grace Hopper and a Leave a comment card with a Write a comment button

What a new comment can become

POST /article-comments answers 201 with the stored comment. The section reads its status and approval and never calls a comment posted unless it is public:

statusapprovalWhenWhat the section says
"published"nullcreate.status: "published", as hereComment posted, and the thread refreshes
"draft"nullcreate.status: "draft"Comment saved as a draft; staff publish it in the AdminCP
"draft"{ status: "pending" }The article sits in a container that holds new comments for approvalYour comment is waiting for a moderator

A Newsroom comment is never pending, because articles are not filed in a container. Pending comments need two more settings: mark the root relation container: true, and give its target a field.container() whose definition has policies. The Forum plugin's comments are set up this way:

plugins/forum/src/content/comment.ts (excerpt)
topic: field.relation({
  target: () => topicContentType,
  required: true,
  container: true, // the comment follows its topic's forum and its rules
  onDelete: "cascade",
}),

A moderator then sets New posts to Wait for approval for comments in a forum's publishing rules. Approvals explains the difference between a draft and a pending submission.

What the server checks

  • Signed out. POST /article-comments answers 401; the section offers guests a sign-in link instead of the composer.
  • Placement. The article must be public (or hidden, for a reader allowed to see it), the parent must belong to the same article, and nothing may nest deeper than maxDepth. A broken rule answers 400, and the section shows why without losing the text.
  • Hidden articles. A comment is only as public as its article. Once the article is unpublished, its comments drop out of the thread and their own routes answer 404. A hidden article keeps its conversation going for the people who can still see it, which means its authors, editors and moderators. Their response is never cached, and everybody else gets 404.
  • Repeated clicks. Post comment is disabled while the request runs, so one click sends one comment.
  • Comment actions: edit, delete, hide and report comments in the same section.
  • Comments API: every option of useContentComments and the shared components.