← Field notes / Guide

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-react

FieldCraft'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 type maps 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 FormResponse with values, schema ID, version, and timing metadata
  • Accessibilityaria-required, aria-invalid, aria-describedby for 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-templates
import { 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

Further Reading