Documentation

Create form

Build a page where members write VitNode Content Engine records with AutoForm and useContentPublicWrite, which shows server errors in the form, and send new records to the right page.

This page builds /news/new, where signed-in members write an article. It takes one form and one request. useContentPublicWrite from core sends the request and shows every refusal, so the page only decides what happens after a successful save.

This continues the article from Member writing, which turned on publicWrite with title and body in create.fields.

Add TanStack Query to the plugin

The page refreshes cached lists with useQueryClient, so the plugin needs @tanstack/react-query as a dependency. Run this from the workspace root:

Add TanStack Query
bun add @tanstack/react-query --cwd plugins/newsroom

Write the form

Create src/pages/article-form.tsx. The edit form reuses it:

plugins/newsroom/src/pages/article-form.tsx
import {
  AutoForm,
  type AutoFormOnSubmit,
} from "@vitnode/core/components/form/auto-form";
import { AutoFormEditor } from "@vitnode/core/components/form/fields/editor";
import { AutoFormInput } from "@vitnode/core/components/form/fields/input";
import {
  isRichTextEmpty,
  type RichTextDocument,
  zodRichTextDocument,
} from "@vitnode/core/content";
import { useTranslations } from "use-intl";
import { z } from "zod";

/** The fields the form renders, so server errors on them show in place. */
export const ARTICLE_FORM_FIELDS = ["title", "body"] as const;

export interface ArticleFormValues {
  body: RichTextDocument;
  title: string;
}

const createArticleFormSchema = (defaults: ArticleFormValues) =>
  z.object({
    title: z.string().trim().min(3).max(200).default(defaults.title),
    body: zodRichTextDocument
      .refine(doc => !isRichTextEmpty(doc))
      .default(defaults.body),
  });

export type ArticleFormSchema = ReturnType<typeof createArticleFormSchema>;

export const ArticleForm = ({
  defaultValues,
  onSubmit,
  submitLabel,
}: {
  defaultValues: ArticleFormValues;
  onSubmit: AutoFormOnSubmit<ArticleFormSchema>;
  submitLabel: string;
}) => {
  const t = useTranslations("newsroom.articles.form");

  return (
    <AutoForm
      fields={[
        {
          id: "title",
          component: props => <AutoFormInput {...props} label={t("title")} />,
        },
        {
          id: "body",
          component: props => (
            <AutoFormEditor {...props} format="json" label={t("body")} />
          ),
        },
      ]}
      formSchema={createArticleFormSchema(defaultValues)}
      onSubmit={onSubmit}
      submitButtonProps={{ children: submitLabel, className: "self-start" }}
    />
  );
};

The schema checks the values in the browser first, with the same limits as the definition, so members see mistakes before anything is sent. format="json" makes the editor produce the document a rich text field stores. AutoForm disables the submit button while onSubmit runs, so a double click never posts twice.

Write the page

Create src/pages/article-new-page.tsx:

plugins/newsroom/src/pages/article-new-page.tsx
import type { AutoFormOnSubmit } from "@vitnode/core/components/form/auto-form";

import { useQueryClient } from "@tanstack/react-query";
import { emptyRichTextDocument } from "@vitnode/core/content";
import { definePluginRoute } from "@vitnode/core/routing";
import { useContentPublicWrite } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useHrefNavigate } from "@vitnode/core/tanstack/link";
import { toast } from "sonner";
import { useTranslations } from "use-intl";

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

import {
  ARTICLE_FORM_FIELDS,
  ArticleForm,
  type ArticleFormSchema,
} from "./article-form";

export const route = definePluginRoute({
  head: ({ t }) => ({
    robots: "noindex, nofollow",
    title: t("newsroom.articles.new.title"),
  }),
});

const ArticleNewPage = () => {
  const t = useTranslations("newsroom.articles.new");
  const navigate = useHrefNavigate();
  const queryClient = useQueryClient();
  const write = useContentPublicWrite({
    fields: ARTICLE_FORM_FIELDS,
    uniqueField: "title",
  });

  const onSubmit: AutoFormOnSubmit<ArticleFormSchema> = async (
    values,
    form,
  ) => {
    const record = await write.submit(
      form,
      async () =>
        await fetcher({
          plugin: manifest.id,
          method: "post",
          module: "content/news",
          path: "/",
          args: { body: { values } },
        }),
    );
    if (!record) return;

    await queryClient.invalidateQueries({
      queryKey: [manifest.id, "articles"],
    });

    // Waits for a moderator. Its public page answers 404 until approval.
    if (record.approval?.status === "pending") {
      toast.info(t("pending.title"), { description: t("pending.desc") });
      await navigate(`/news/${record.id}/edit`);

      return;
    }

    // A draft nobody has to review. It stays private, so open its edit page.
    if (record.status !== "published") {
      toast.info(t("draft.title"), { description: t("draft.desc") });
      await navigate(`/news/${record.id}/edit`);

      return;
    }

    toast.success(t("success.title"), { description: t("success.desc") });
    await navigate(`/news/${record.slug}`);
  };

  return (
    <div className="container mx-auto flex max-w-3xl flex-col gap-6 px-4 py-8">
      <h1 className="text-3xl font-semibold tracking-tight text-balance">
        {t("title")}
      </h1>
      <ArticleForm
        defaultValues={{ body: emptyRichTextDocument(), title: "" }}
        onSubmit={onSubmit}
        submitLabel={t("submit")}
      />
    </div>
  );
};

export default ArticleNewPage;
  • write.submit sends the request and returns the new record, or null after showing why the server said no: an issue on a field appears under it, a taken title appears under Title (uniqueField), and anything else, such as an expired session, is a toast. The Form helpers reference lists every case.

  • The owner comes from the server; values never includes author.

  • invalidateQueries expires the cached article list, whose query key starts with [manifest.id, "articles"], so the new article shows there at once.

  • Before navigating, the page reads two fields of the answer, because a new article can end in three states. Only a published one has a public page; the other two answer 404 there, so the author goes to the edit page with a message that says what happened:

    statusapprovalWhat the author hears
    "published"nullArticle published, on the article's page
    "draft"nullDraft saved. It is private and nobody was asked to review it
    "draft"{ status: "pending" }Sent for approval. A moderator reviews it first

    Newsroom articles use create.status: "published" and have no container, so members always get the first row. create.status: "draft" gives the second. The third only happens for a content type filed in a container whose publishing rules hold new posts for approval; see Drafts and approval.

Add the route

Edit src/routes.ts and add the page before /news/:slug. This is an excerpt:

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

  page("/news/:slug", {

requires: "authenticated" sends guests to the sign-in page and brings them back afterwards. The static /news/new wins over /news/:slug, so an article whose slug is new would be hidden behind the form; pick another URL, such as /news/write/article, if that can happen in your app.

Add the labels

Add these keys under newsroom.articles in src/locales/en.json:

plugins/newsroom/src/locales/en.json
{
  "newsroom": {
    "articles": {
      "form": { "title": "Title", "body": "Article" },
      "new": {
        "title": "Write an article",
        "submit": "Publish article",
        "success": { "title": "Article published", "desc": "Readers can see it now." },
        "draft": {
          "title": "Draft saved",
          "desc": "It is not public yet, so only you and the site's staff can see it."
        },
        "pending": {
          "title": "Sent for approval",
          "desc": "A moderator reviews it before readers can see it."
        }
      }
    }
  }
}

Error messages come from core and follow the reader's language, so the plugin needs none.

Check the result

Sign in as a member and open http://localhost:3000/news/new:

  1. Click Publish article with an empty form. Both fields show an error, and nothing is sent.
  2. Enter the title Welcome to the newsroom, which an article already has, and a message. This is already taken. Try something else. appears under Title, and your text stays.
  3. Change the title and click Publish article. Article published appears, you land on /news/{slug}, and the article tops /news.
  4. Open /news/new signed out. The app sends you to the sign-in page.
  5. Optional: set create.status to "draft" in src/content/article.ts and post again. Draft saved appears, you land on the edit page, and the article is missing from /news until staff publish it. Set it back to "published" afterwards.
The Write an article form with the title Welcome to the newsroom, the error This is already taken. Try something else. under it, and a message in the Article editor

Captcha

With create.captcha: true in publicWrite, every create needs a captcha token. Give ArticleForm a captcha prop that it passes on to AutoForm, then forward the token AutoForm hands to onSubmit:

const { data: config } = useMiddlewareConfigQuery(); // from @vitnode/core/tanstack/auth

const onSubmit: AutoFormOnSubmit<ArticleFormSchema> = async (values, form, { captchaToken }) => {
  const record = await write.submit(form, async () =>
    await fetcher({
      plugin: manifest.id,
      method: "post",
      module: "content/news",
      path: "/",
      captchaToken,
      args: { body: { values } },
    }),
  );
};

// In ArticleForm: <AutoForm captcha={captcha} ... />
<ArticleForm captcha={config?.captcha} /* ...the other props */ />;

Next, let authors fix their articles with an Edit form.