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 for a headless Cmssy site. You manage the form in Cmssy's Form Builder (admin on cmssy.io) and render it in your own Next.js (App Router) app, which you deploy yourself (Vercel etc.). The Cmssy visual editor frames your deployed site so editors can tweak content live. This setup includes:
- Name, email, and message fields with validation - defined once in the Form Builder
- Server-side validation, email notifications, and webhooks handled by Cmssy
- Submissions stored in Cmssy and viewable in the admin
- Success/error states with animations, rendered in your app
- Responsive design with Tailwind CSS
Prerequisites
- Node.js 18+ installed
- A Next.js (App Router) app with the SDK:
npm install @cmssy/next @cmssy/react - A Cmssy workspace with an API token
Step 1: Create the Form in Cmssy
In the Cmssy admin, go to Dashboard → Forms → Create Form. Give it a name and slug (e.g. contact-form), then add fields:
- name - text, required
- email - email, required
- message - textarea, required, min length 10
Under Settings, pick the contact action type, add email recipients, and set the submit label, success message, and error message. Labels and messages are multilingual, so you author en and pl in the same place. Publish the form so it accepts submissions.
Step 2: Define the Block
In your Next.js app, create blocks/contact-form/block.ts. The schema defines the fields editors see in the Cmssy editor - here, a form field lets them pick which form to render:
import { defineBlock, fields } from "@cmssy/react";
import Component from "./src";
export const contactFormBlock = defineBlock({
type: "contact-form", // unique block type id, stored on each instance
label: "Contact Form",
component: Component,
schema: {
form: fields.form({ label: "Form" }),
heading: fields.singleLine({ label: "Heading", defaultValue: "Get in Touch" }),
description: fields.richText({ label: "Description" }),
},
});Step 3: Build the Component
Create blocks/contact-form/src/index.tsx. It fetches the form definition from Cmssy with createCmssyClient (from @cmssy/react) and submits to the public form mutation:
"use client";
import { useState } from "react";
import { createCmssyClient } from "@cmssy/react";
import type { CmssyBlockContext } from "@cmssy/react";
const client = createCmssyClient();
interface Props {
content: { form?: string; heading?: string; description?: string };
context?: CmssyBlockContext;
}
export default function ContactForm({ content, context }: Props) {
const { form: formId, heading = "Get in Touch", description } = content;
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
const [values, setValues] = useState<Record<string, string>>({});
const isEditor = context?.isEditor ?? false;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isEditor || !formId) return; // Don't submit in editor preview
setStatus("sending");
try {
// Submits to cmssy: validates, stores the submission, emails recipients
const res = await client.submitForm({ formId, data: values });
setStatus(res.success ? "success" : "error");
} catch {
setStatus("error");
}
};
if (status === "success") {
return (
<section className="py-24">
<div className="max-w-lg mx-auto text-center px-6">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100
flex items-center justify-center">
<svg width={32} height={32} fill="none" stroke="#22c55e"
viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round"
d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-2xl font-bold mb-2">Thanks! We'll get back to you soon.</h3>
</div>
</section>
);
}
return (
<section className="py-24">
<div className="max-w-lg mx-auto px-6">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold mb-3">{heading}</h2>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium mb-1.5">Name</label>
<input
type="text"
required
value={values.name ?? ""}
onChange={(e) => setValues({ ...values, name: e.target.value })}
className="w-full px-4 py-2.5 border rounded-lg bg-background
focus:outline-none focus:ring-2 focus:ring-violet-500/20
focus:border-violet-500"
placeholder="Your name"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">Email</label>
<input
type="email"
required
value={values.email ?? ""}
onChange={(e) => setValues({ ...values, email: e.target.value })}
className="w-full px-4 py-2.5 border rounded-lg bg-background
focus:outline-none focus:ring-2 focus:ring-violet-500/20
focus:border-violet-500"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">Message</label>
<textarea
required
rows={5}
value={values.message ?? ""}
onChange={(e) => setValues({ ...values, message: e.target.value })}
className="w-full px-4 py-2.5 border rounded-lg bg-background
resize-none focus:outline-none focus:ring-2
focus:ring-violet-500/20 focus:border-violet-500"
placeholder="How can we help?"
/>
</div>
<button
type="submit"
disabled={status === "sending"}
className="w-full py-3 rounded-lg bg-violet-600 hover:bg-violet-700
text-white font-medium transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
{status === "error" && (
<p className="text-sm text-red-500 text-center">
Something went wrong. Please try again.
</p>
)}
</form>
</div>
</section>
);
}Tip: instead of hard-coding fields, you can fetch the form definition with the client and render form.fields dynamically, so editing the form in Cmssy updates your UI without a code change.
Step 4: Register the Block
Add the block to your block registry so the Cmssy editor knows it exists. Create or edit cmssy/blocks.ts:
import { contactFormBlock } from "@/blocks/contact-form/block";
export const blocks = [contactFormBlock];That array is the single source of truth for which blocks your site supports.
Step 5: Deploy Your Site
Cmssy doesn't host your site - you do. Deploy your Next.js app to Vercel (or any host) as usual:
git push # then deploy via Vercel, or your CI of choiceOnce deployed, point your workspace at the live URL so the Cmssy visual editor can frame your site for editing.
Step 6: Use on a Page
In the Cmssy editor:
- Click the + button to add a block
- Find Contact Form in the block list
- Drop it onto your page
- In the sidebar, pick the form you created in Step 1
- Set the heading and description
- Publish the page
Submissions now land in Dashboard → Forms, where you can review, mark as processed, or export them.
Key Patterns
Editor Safety
Always check context.isEditor before submitting, so the form doesn't post real data while an editor is previewing:
const isEditor = context?.isEditor ?? false;
if (isEditor) return; // Skip submission in editor previewForm Builder = One Source of Truth
Fields, validation, and i18n labels live in the Form Builder. Your block just renders them and calls submitForm. Change the form in Cmssy and every site that renders it stays in sync - no redeploy needed for content tweaks.
Server-Side Validation
The public submitForm mutation validates fields server-side, stores the submission, sends email notifications, and calls any configured webhook - so your block stays thin and secure.
Next Steps
- Explore field types and validation in the Forms & Form Builder docs
- Set up your project with the Installation guide
- Learn the full
defineBlock/fieldsAPI in the Blocks docs