Tutorial

How to Build a Contact Form Block

Create a contact form in Cmssy's Form Builder, then render it in your own headless Next.js site with @cmssy/react. Validation, submissions, and email handled by Cmssy.

C
Cmssy Team
12 min read

How to Build a Contact Form Block

Build a headless contact form: create it in Cmssy's Form Builder, then render it in your own Next.js site with the SDK.

What We're Building

A contact form block: you define the form once in the Cmssy Form Builder, and your block renders it in your own Next.js app. Cmssy handles validation, stores the submission and emails the recipients; your app owns the markup and the styling.

The split matters. The form definition - fields, labels, validation, success message, all localized - is content, so it lives in the CMS and an editor can change it without a deploy. The rendering is code, so it lives in your repo.

Prerequisites

  • A Next.js (App Router) app wired to your workspace with @cmssy/react and @cmssy/next - see the installation guide
  • GraphQL codegen set up, so SubmitFormDocument is generated for you

Step 1: Create the Form in Cmssy

In the Cmssy admin, go to Forms and create one:

  1. Add the fields you need - name (text), email (email), message (textarea) - each with a localized label and validation rules
  2. Set the action type to contact and add the recipient addresses
  3. Write the submit button label and the success message, per language
  4. Set the status to published so the form accepts submissions

Step 2: Define the Block

Create blocks/contact/block.ts. The fields.form builder gives the editor a form picker; putting it on the advanced tab keeps it out of the way of everyday copy edits:

import { defineBlock, fields } from "@cmssy/react";
import Contact from "./Contact";

export const contactProps = {
  heading: fields.text({ label: "Heading" }),
  description: fields.textarea({ label: "Description" }),
  formId: fields.form({ label: "Form", tab: "advanced" }),
  submitLoadingText: fields.text({
    label: "Submit Loading Text",
    defaultValue: "Sending...",
  }),
  successHeading: fields.text({
    label: "Success Heading",
    defaultValue: "Message Sent!",
  }),
};

export const contactBlock = defineBlock({
  type: "contact",
  category: "Forms",
  label: "Contact",
  description:
    "Contact details and/or contact form; near the end of a page or on a dedicated contact page.",
  component: Contact,
  props: contactProps,
});

Step 3: Build the Component

The block does not fetch the form. The SDK resolves every form referenced on the page and hands the definitions to your component on context.forms, keyed by id. Because the field sits on the advanced tab, its value arrives in the advanced prop:

import type { BlockProps } from "@cmssy/react";
import type { contactProps } from "./block";
import { ContactForm } from "./ContactForm";

export default function Contact({
  content,
  context,
  advanced = {},
}: BlockProps<typeof contactProps>) {
  const { heading, description, successHeading, submitLoadingText } = content;
  const { formId } = advanced as { formId?: string };
  const formDef = formId ? (context?.forms?.[formId] ?? null) : null;

  return (
    <section className="py-24">
      <div className="max-w-lg mx-auto px-6">
        {heading && <h2 className="text-3xl font-bold">{heading}</h2>}
        {description && (
          <p className="mt-3 text-muted-foreground">{description}</p>
        )}

        {formDef?.fields?.length && formId ? (
          <ContactForm
            formDef={formDef}
            formId={formId}
            successHeading={successHeading}
            submitLoadingText={submitLoadingText}
          />
        ) : null}
      </div>
    </section>
  );
}

Note the guard: no form selected means no form rendered. A block shows what the CMS gives it and nothing else - never a hardcoded fallback heading that would leak English onto a Polish page.

Step 4: Submit Through a Server Action

Submission is a GraphQL mutation, and it belongs on the server so your delivery credentials never reach the browser. Put the call in services/forms.ts:

import { print } from "graphql";
import { createCmssyClient } from "@cmssy/react";
import { cmssy } from "@/cmssy/config";
import {
  SubmitFormDocument,
  type SubmitFormMutation,
} from "@/graphql/generated/graphql";

const client = createCmssyClient(cmssy);

export async function submitForm(
  formId: string,
  data: Record<string, string>,
) {
  const res = await client.queryScoped<SubmitFormMutation>(
    print(SubmitFormDocument),
    { formId, input: { data } },
  );
  const result = res.public.form.submit;
  return { success: result.success, message: result.message };
}

Then wrap it in a server action in blocks/contact/actions.ts. The website field is a honeypot: it is hidden from humans, so anything filling it is a bot and gets a fake success:

"use server";

import { submitForm } from "@/services/forms";
import type { ContactState } from "./types";

export async function submitContact(
  formId: string,
  _prevState: ContactState,
  formData: FormData,
): Promise<ContactState> {
  if (formData.get("website")) {
    return { status: "success", message: null };
  }

  const data: Record<string, string> = {};
  for (const [key, value] of formData.entries()) {
    if (key === "website") continue;
    if (typeof value === "string" && value) data[key] = value;
  }

  try {
    const result = await submitForm(formId, data);
    return {
      status: result.success ? "success" : "error",
      message: result.message,
    };
  } catch {
    return { status: "error", message: null };
  }
}

Step 5: Render the Fields

The client half renders whatever fields the form definition declares and calls the action with useActionState. Nothing about the field list is hardcoded - add a field in the Form Builder and it appears here without a deploy:

"use client";

import { useActionState } from "react";
import type { CmssyFormDefinition } from "@cmssy/react";
import { submitContact } from "./actions";
import type { ContactState } from "./types";

const INITIAL_STATE: ContactState = { status: "idle", message: null };

export function ContactForm({
  formDef,
  formId,
  successHeading,
  submitLoadingText,
}: {
  formDef: CmssyFormDefinition;
  formId: string;
  successHeading: string;
  submitLoadingText: string;
}) {
  const [state, formAction, isPending] = useActionState(
    submitContact.bind(null, formId),
    INITIAL_STATE,
  );

  if (state.status === "success") {
    return <p>{successHeading}</p>;
  }

  return (
    <form action={formAction} className="mt-10 space-y-5">
      <input type="text" name="website" tabIndex={-1} className="hidden" />

      {formDef.fields.map((field) => (
        <div key={field.id}>
          <label className="block text-sm font-medium mb-1.5">
            {field.label}
          </label>
          {field.fieldType === "textarea" ? (
            <textarea
              name={field.name}
              rows={5}
              required={field.validation?.required}
              className="w-full px-4 py-2.5 border rounded-lg"
            />
          ) : (
            <input
              type={field.fieldType}
              name={field.name}
              required={field.validation?.required}
              className="w-full px-4 py-2.5 border rounded-lg"
            />
          )}
        </div>
      ))}

      <button type="submit" disabled={isPending}>
        {isPending ? submitLoadingText : "Send"}
      </button>

      {state.status === "error" && (
        <p className="text-sm text-red-500">{state.message}</p>
      )}
    </form>
  );
}

The label is localized in the CMS, so each language gets its own without a branch in your code.

Step 6: Register the Block

Add it to the array in cmssy/blocks.ts - the same array you pass to createCmssyPage:

import { contactBlock } from "@/blocks/contact/block";
// ...your other blocks

export const blocks = [contactBlock];

Step 7: Deploy and Use It

There is no separate block deploy step - the block ships when your Next.js app does:

git push   # then deploy via Vercel, or your CI of choice

Then open a page in the Cmssy editor, drop in the Contact block, pick your form on the Advanced tab, and publish. Submissions land under Forms in the admin and go out to the recipients you configured.

Key Patterns

  • Form definition is content - fields, labels and validation live in the CMS, so editors change them without a deploy
  • Submission is server-side - a server action keeps your delivery credentials out of the browser
  • Honeypot over captcha - a hidden field costs nothing and stops the bulk of bots
  • Skip real submits in the editor - check context.isPreview before wiring anything destructive
  • Render nothing rather than a default - no form picked means no form, not a placeholder

Next Steps