Tutorial

How to Build a Contact Form Block

Create a custom contact form block with React and Tailwind CSS. Includes form validation, email integration, and success states.

C
Cmssy Team
12 min read

How to Build a Contact Form Block

Complete tutorial with code examples for building a production-ready contact form block.

Last updated: March 15, 2026

What We're Building

A fully functional contact form block that you can drop onto any Cmssy page. It includes:

  • Name, email, and message fields with validation
  • Configurable recipient email and subject via block schema
  • Success/error states with animations
  • Responsive design with Tailwind CSS
  • Integration with Cmssy's form system

Prerequisites

  • Node.js 18+ installed
  • @cmssy/cli installed globally: npm install -g @cmssy/cli
  • A Cmssy workspace with API token

Step 1: Initialize the Block

Create the block directory and files:

mkdir -p blocks/contact-form/src
cd blocks/contact-form

Create package.json:

{
  "name": "@my-project/blocks.contact-form",
  "version": "1.0.0",
  "description": "Contact form block with validation"
}

Step 2: Define the Schema

Create config.ts - this defines the editable fields admins see in the page editor:

import { defineBlock } from "@cmssy/cli/config";

export default defineBlock({
  name: "Contact Form",
  description: "Contact form with name, email, message fields",
  category: "marketing",
  tags: ["form", "contact", "email"],
  schema: {
    heading: {
      type: "singleLine",
      label: "Heading",
      defaultValue: "Get in Touch",
      placeholder: "Form heading",
    },
    description: {
      type: "multiLine",
      label: "Description",
      defaultValue: "Have a question? Send us a message.",
    },
    submitButtonText: {
      type: "singleLine",
      label: "Submit Button Text",
      defaultValue: "Send Message",
    },
    successMessage: {
      type: "singleLine",
      label: "Success Message",
      defaultValue: "Thanks! We'll get back to you soon.",
    },
    showPhone: {
      type: "boolean",
      label: "Show Phone Field",
      defaultValue: false,
    },
  },
});

Step 3: Build the Component

Create src/ContactForm.tsx:

"use client";

import { useState } from "react";
import type { PlatformContext } from "@cmssy/types";
import { BlockContent } from "./block";

interface Props {
  content: BlockContent;
  context?: PlatformContext;
}

export default function ContactForm({ content, context }: Props) {
  const {
    heading = "Get in Touch",
    description = "Have a question? Send us a message.",
    submitButtonText = "Send Message",
    successMessage = "Thanks! We'll get back to you soon.",
    showPhone = false,
  } = content;

  const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    phone: "",
    message: "",
  });

  const isEditor = context?.isEditor ?? false;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (isEditor) return; // Don't submit in editor

    setStatus("sending");
    try {
      const res = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(formData),
      });
      setStatus(res.ok ? "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">{successMessage}</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={formData.name}
              onChange={(e) => setFormData({ ...formData, 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={formData.email}
              onChange={(e) => setFormData({ ...formData, 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>

          {showPhone && (
            <div>
              <label className="block text-sm font-medium mb-1.5">Phone</label>
              <input
                type="tel"
                value={formData.phone}
                onChange={(e) => setFormData({ ...formData, phone: 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="+1 (555) 000-0000"
              />
            </div>
          )}

          <div>
            <label className="block text-sm font-medium mb-1.5">Message</label>
            <textarea
              required
              rows={5}
              value={formData.message}
              onChange={(e) => setFormData({ ...formData, 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..." : submitButtonText}
          </button>

          {status === "error" && (
            <p className="text-sm text-red-500 text-center">
              Something went wrong. Please try again.
            </p>
          )}
        </form>
      </div>
    </section>
  );
}

Step 4: Add Entry Point and Styles

Create src/index.tsx:

export { default } from "./ContactForm";
import "./index.css";

Create src/index.css:

@import "../../../styles/main.css";

Step 5: Build and Publish

# Build the block
cmssy build

# Publish to your workspace
cmssy publish

Step 6: Use on a Page

In the Cmssy page editor:

  1. Click the + button to add a block
  2. Find Contact Form in the Marketing category
  3. Drag it onto your page
  4. Configure heading, description, and button text in the sidebar
  5. Toggle the phone field on/off as needed
  6. Publish the page

Key Patterns

Editor Safety

Always check context.isEditor before making API calls or enabling interactive features:

const isEditor = context?.isEditor ?? false;
if (isEditor) return; // Skip in editor preview

Schema = Admin UI

The schema in config.ts automatically generates the editing interface. Use boolean fields for toggles, singleLine for short text, and multiLine for longer content.

Conditional Fields

Use showWhen in your schema to show fields only when a condition is met:

phoneLabel: {
  type: "singleLine",
  label: "Phone Label",
  defaultValue: "Phone",
  showWhen: { field: "showPhone", equals: true },
}

Next Steps

How to Build a Contact Form Block - Cmssy Tutorial | Cmssy