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/reactand@cmssy/next- see the installation guide - GraphQL codegen set up, so
SubmitFormDocumentis generated for you
Step 1: Create the Form in Cmssy
In the Cmssy admin, go to Forms and create one:
- Add the fields you need -
name(text),email(email),message(textarea) - each with a localized label and validation rules - Set the action type to contact and add the recipient addresses
- Write the submit button label and the success message, per language
- 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 choiceThen 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.isPreviewbefore wiring anything destructive - Render nothing rather than a default - no form picked means no form, not a placeholder
Next Steps
- Read the Block Development Guide
- See all field types and schema options
- Learn about forms and submissions
- Follow the installation guide to set up
@cmssy/reactand@cmssy/next