Now with AI-powered page building via MCP Server
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.

Last updated: March 15, 2026

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 choice

Once 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:

  1. Click the + button to add a block
  2. Find Contact Form in the block list
  3. Drop it onto your page
  4. In the sidebar, pick the form you created in Step 1
  5. Set the heading and description
  6. 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 preview

Form 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