Documentation

Public create form

Build a page where members create Content Engine records with one AutoForm, one fetcher call and useContentPublicWrite, which shows server errors for you.

This guide builds /discussions/new, where members start a discussion. It takes one form and one request. When the server says no, useContentPublicWrite from core deals with it: field errors land under their fields, and the rest become translated toasts.

You need a content type with public write turned on.

Add the route

plugins/example/src/routes.ts
page("/discussions/new", {
  component: lazy(() => import("./pages/discussion-new-page")),
  messages: ["@vitnode/example.discussions"],
  requires: "authenticated",
}),

requires: "authenticated" sends guests to the sign-in page and brings them back afterwards. messages loads the labels below.

Write the page

src/pages/discussion-new-page.tsx
import { useQueryClient } from "@tanstack/react-query";
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 {
  emptyRichTextDocument,
  isRichTextEmpty,
  zodRichTextDocument,
} from "@vitnode/core/content";
import { useContentPublicWrite } from "@vitnode/core/tanstack/content";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useHrefNavigate } from "@vitnode/core/tanstack/link";
import { useTranslations } from "use-intl";
import { z } from "zod";

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

import { invalidateDiscussionLists } from "./discussion-list";

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

const DiscussionNewPage = () => {
  const t = useTranslations("@vitnode/example.discussions");
  const navigate = useHrefNavigate();
  const queryClient = useQueryClient();
  const write = useContentPublicWrite({
    fields: ["title", "body"],
    uniqueField: "title",
  });

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

    await invalidateDiscussionLists(queryClient);
    await navigate(`/discussions/${record.slug}?created=true`);
  };

  return (
    <AutoForm
      fields={[
        {
          id: "title",
          component: props => <AutoFormInput {...props} label={t("form.title.label")} />,
        },
        {
          id: "body",
          component: props => (
            <AutoFormEditor {...props} format="json" label={t("form.body.label")} />
          ),
        },
      ]}
      formSchema={formSchema}
      onSubmit={onSubmit}
      submitButtonProps={{ children: t("new.submit") }}
    />
  );
};

export default DiscussionNewPage;

That is the whole page. Here is what each part does:

  • formSchema checks the values in the browser first. Copy the server's limits, such as minLength: 3, so members see mistakes before they submit.
  • write.submit sends the request and returns the new record, or null when the server said no. It already showed the reason: a server issue on title appears under the title, a taken title too (uniqueField), and anything else, such as an expired session, is a toast. You write no error handling.
  • The owner is set by the server. values never includes the author.
  • Double posts cannot happen, because AutoForm disables the button while onSubmit runs.
  • invalidateDiscussionLists refreshes the cached discussion list, so the new discussion shows up there at once.
  • format="json" makes the editor produce the document a rich text field stores.

This page fits the discussion type from Public write. The example plugin's own discussions also live in boards, so its page in plugins/example/src/pages/ adds a board select and sends board in values; any container field in create.fields works the same way.

Add the labels

plugins/example/src/locales/en.json
{
  "@vitnode/example": {
    "discussions": {
      "new": { "submit": "Post discussion" },
      "form": {
        "title": { "label": "Title" },
        "body": { "label": "Message" }
      }
    }
  }
}

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

When a post waits for a moderator

In a container with publishing policies that ask for approval, a create still succeeds but the record is not public yet. record.approval?.status is "pending" and its page answers 404. Send the author somewhere that says so:

if (record.approval?.status === "pending") {
  await navigate(`/discussions/${record.id}/edit`);

  return;
}

The edit page shows the waiting notice, as Approvals explains.

Captcha

With create.captcha: true, pass the captcha config to the form and forward the token it hands to onSubmit:

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

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

<AutoForm captcha={config?.captcha} /* ... */ />;

Check the result

Sign in as a member and open /discussions/new:

  1. Leave the message empty and submit. The form says so without sending anything.
  2. Use the title of an existing discussion. This is already taken appears under the title.
  3. Write a new title and a message, then click Post discussion. You land on the new discussion, and it tops /discussions.
The Start a discussion form with a board, a title and an empty Message editor outlined in red under an error