How to Build a React Contact Form with JSON Schema
Build a production-ready React contact form from a single JSON schema. Validation, error handling, and submission in under 50 lines of code.
TL;DR — Define your contact form as a JSON schema. FieldCraft handles rendering, validation, error display, and submission. You write zero form state code.
The Problem
A typical React contact form starts simple — name, email, message — and quickly grows. You add validation. Then error messages. Then a loading state on submit. Then a success message. Then conditional fields. Then accessibility attributes. Before you know it, a "simple" contact form is 200 lines of JSX, state management, and validation logic.
What if the form was data instead of code?
What We're Building
A two-section contact form with:
- Section 1: Name (required, 2-100 chars), email (required, validated format), phone (optional)
- Section 2: Subject dropdown, message textarea (required, 20-2000 chars)
- Step progress, per-section validation, and callback submission
Setup
npm install @squaredr/fieldcraft-core @squaredr/fieldcraft-reactFieldCraft's only peer dependency is zod. If your project already uses it, nothing is duplicated.
The Schema
The entire form is a single JSON object. No JSX, no useState, no validation wiring.
import type { FormEngineSchema } from "@squaredr/fieldcraft-core";
const contactSchema: FormEngineSchema = {
id: "contact-form",
version: "1.0.0",
title: "Contact Us",
description:
"Get in touch with our team. We'll respond within one business day.",
settings: {
showProgress: true,
progressStyle: "steps",
},
submitAction: { type: "callback" },
sections: [
{
id: "personal-info",
title: "Your Details",
description: "Tell us who you are",
questions: [
{
id: "name",
type: "short_text",
label: "Full Name",
required: true,
placeholder: "Jane Doe",
validation: [
{
type: "minLength",
value: 2,
message: "Name is too short",
},
{ type: "maxLength", value: 100 },
],
},
{
id: "email",
type: "email",
label: "Email Address",
required: true,
placeholder: "you@company.com",
helpText:
"We'll never share your email with third parties",
},
{
id: "phone",
type: "phone",
label: "Phone Number",
required: false,
placeholder: "(555) 123-4567",
helpText:
"Optional — only used if we need to reach you urgently",
},
],
},
{
id: "inquiry",
title: "Your Message",
description: "How can we help?",
questions: [
{
id: "subject",
type: "dropdown",
label: "Subject",
required: true,
options: [
{ label: "General Inquiry", value: "general" },
{ label: "Technical Support", value: "support" },
{ label: "Sales & Pricing", value: "sales" },
{ label: "Partnership", value: "partnership" },
{ label: "Other", value: "other" },
],
},
{
id: "message",
type: "long_text",
label: "Message",
required: true,
placeholder: "Describe your inquiry in detail...",
validation: [
{
type: "minLength",
value: 20,
message:
"Please provide at least 20 characters",
},
{ type: "maxLength", value: 2000 },
],
},
],
},
],
};That's the entire form definition. Five fields, two sections, validation rules, help text, placeholders — all in one object.
Rendering
One component, one prop for the schema, one callback for submission:
"use client"; // Next.js — omit if using Vite/CRA
import { FormEngineRenderer } from "@squaredr/fieldcraft-react";
export default function ContactPage() {
return (
<FormEngineRenderer
schema={contactSchema}
onSubmit={(response) => {
// response.values = { name, email, phone, subject, message }
console.log("Submitted:", response);
fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(response.values),
});
}}
/>
);
}That's it. The renderer handles:
- Field rendering — each
typemaps to the correct input component (text input, email input, phone input, dropdown, textarea) - Validation — rules run on blur, errors display below each field
- Navigation — "Next" validates the current section before advancing, "Back" moves to the previous section
- Progress — "Step 1 of 2" indicator at the top
- Submission — builds a
FormResponsewith values, schema ID, version, and timing metadata - Accessibility —
aria-required,aria-invalid,aria-describedbyfor help text and errors,role="alert"on error containers
What You Get for Free
Validation on blur
When the user tabs away from the email field with an invalid value, the error appears immediately. When they fix it, the error disappears. No submit-to-see-errors pattern.
Help text
The helpText property renders below each field with proper aria-describedby linking. Screen readers announce it when the field receives focus.
Section-level validation
Clicking "Next" validates only the current section's fields. The user doesn't see errors for fields they haven't reached yet.
Keyboard navigation
Tab through fields, Enter to advance, full keyboard accessibility out of the box.
Adding a Conditional Field
What if you want to show a "Please specify" text field when the user selects "Other" as the subject? Add one field with a showIf condition:
{
id: "other_subject",
type: "short_text",
label: "Please specify",
required: true,
showIf: {
field: "subject",
operator: "eq",
value: "other",
},
}Add this to the inquiry section's questions array. The field only appears when subject equals "other". When it's hidden, it's excluded from validation and submission — the user is never asked to fill in a field they can't see.
Using the Pre-Built Template
FieldCraft ships 16 free templates, including a contact form identical to the one above:
npm install @squaredr/fieldcraft-templatesimport { contactForm } from "@squaredr/fieldcraft-templates";
// contactForm.schema → the FormEngineSchema
// contactForm.meta → { name, description, fieldCount, tags }Pass contactForm.schema to FormEngineRenderer and you're done.
What's Next
- Your first form — a more detailed walkthrough with conditional logic
- Validation docs — all 19 built-in validators plus custom and async validators
- Templates gallery — 16 free schemas across 7 categories
- Schema anatomy — the complete schema reference
Further Reading
- Build a Multi-Step Survey in 5 Minutes — a more complex multi-step example
- Why Schema-Driven? — the architecture behind defining forms as data
- 6 Hard-Won UX Lessons from Building a Form Engine — why validate on blur, not on change