How to Build a Contact Form Block
Complete tutorial with code examples for building a production-ready contact form block.
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/cliinstalled 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-formCreate 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 publishStep 6: Use on a Page
In the Cmssy page editor:
- Click the + button to add a block
- Find Contact Form in the Marketing category
- Drag it onto your page
- Configure heading, description, and button text in the sidebar
- Toggle the phone field on/off as needed
- 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 previewSchema = 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
- Add conditional fields to your schema
- Connect to collection blocks for dynamic content
- Read the Block Development Guide for advanced patterns