Back to Blog

React Dynamic Form Builder in 2026: JSON Schema, Drag-and-Drop Fields, and Validation

Build a production React form builder: JSON schema-driven field rendering, drag-and-drop field ordering with @dnd-kit, Zod validation, conditional logic, and form response storage.

Viprasol Tech Team
January 10, 2027
14 min read

React Dynamic Form Builder in 2026: JSON Schema, Drag-and-Drop Fields, and Validation

Every SaaS product eventually needs a form builder: intake questionnaires, customer surveys, onboarding forms, registration workflows. Building it well means separating the form schema (data) from the renderer (UI) from the validation (rules). Get that separation right and you can store forms in a database, render them dynamically, validate them server-side, and let non-engineers create new forms without deploying code.

This post builds a complete form builder: a JSON schema that describes field types and validation rules, a drag-and-drop editor with @dnd-kit, a renderer that produces validated forms, conditional field logic, and a response storage system.


Form Schema Design

The schema is the source of truthβ€”stored in the database, version-controlled, and serializable:

// types/form-schema.ts

export type FieldType =
  | "short_text"
  | "long_text"
  | "email"
  | "number"
  | "phone"
  | "url"
  | "date"
  | "select"
  | "multi_select"
  | "checkbox"
  | "radio"
  | "file_upload"
  | "rating"
  | "section_header"
  | "divider";

export interface BaseField {
  id: string;               // UUID
  type: FieldType;
  label: string;
  description?: string;
  placeholder?: string;
  required: boolean;
  hidden: boolean;
}

export interface TextField extends BaseField {
  type: "short_text" | "long_text" | "email" | "phone" | "url";
  minLength?: number;
  maxLength?: number;
  pattern?: string;         // Regex validation pattern
}

export interface NumberField extends BaseField {
  type: "number";
  min?: number;
  max?: number;
  step?: number;
  unit?: string;            // "USD", "kg", etc.
}

export interface SelectField extends BaseField {
  type: "select" | "multi_select" | "radio";
  options: Array<{ value: string; label: string }>;
  allowOther?: boolean;     // "Other" free-text option
}

export interface RatingField extends BaseField {
  type: "rating";
  maxRating: number;        // 1–10
  labels?: { low: string; high: string };
}

export interface FileField extends BaseField {
  type: "file_upload";
  acceptedTypes?: string[];   // ["image/*", ".pdf"]
  maxFileSizeMB?: number;
  maxFiles?: number;
}

export interface LayoutField extends BaseField {
  type: "section_header" | "divider";
}

export type FormField =
  | TextField
  | NumberField
  | SelectField
  | RatingField
  | FileField
  | LayoutField;

// Conditional logic β€” show/hide fields based on other field values
export interface ConditionalRule {
  fieldId: string;            // Watch this field
  operator: "equals" | "not_equals" | "contains" | "greater_than" | "less_than";
  value: string | number | boolean;
  action: "show" | "hide";   // Action for the field this rule is on
}

export interface FormSchema {
  id: string;
  title: string;
  description?: string;
  fields: FormField[];
  submitButtonLabel?: string;
  successMessage?: string;
  version: number;
  createdAt: string;
  updatedAt: string;
}

Database Schema

-- migrations/20260101_forms.sql

CREATE TABLE forms (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  team_id       UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  title         TEXT NOT NULL,
  description   TEXT,
  schema        JSONB NOT NULL,      -- FormSchema (fields, validation, etc.)
  settings      JSONB NOT NULL DEFAULT '{}'::jsonb,
  status        TEXT NOT NULL DEFAULT 'draft',  -- draft | published | archived
  version       INTEGER NOT NULL DEFAULT 1,
  response_count INTEGER NOT NULL DEFAULT 0,
  created_by    UUID NOT NULL REFERENCES users(id),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE form_responses (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  form_id       UUID NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
  respondent_id UUID REFERENCES users(id),   -- NULL for anonymous
  respondent_email TEXT,
  data          JSONB NOT NULL,              -- { fieldId: value, ... }
  metadata      JSONB NOT NULL DEFAULT '{}'::jsonb,  -- IP, user agent, etc.
  submitted_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_forms_team ON forms(team_id, status);
CREATE INDEX idx_responses_form ON form_responses(form_id, submitted_at DESC);

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Form Builder Editor

// components/FormBuilder/FormBuilderEditor.tsx
"use client";

import {
  DndContext,
  DragEndEvent,
  DragOverlay,
  DragStartEvent,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { KeyboardSensor } from "@dnd-kit/core";
import { useState, useCallback } from "react";
import { randomUUID } from "@/lib/utils";
import { FormField, FormSchema, FieldType } from "@/types/form-schema";
import { FieldEditor } from "./FieldEditor";
import { SortableFieldRow } from "./SortableFieldRow";
import { FieldPalette } from "./FieldPalette";

interface Props {
  initialSchema: FormSchema;
  onSave: (schema: FormSchema) => Promise<void>;
}

export function FormBuilderEditor({ initialSchema, onSave }: Props) {
  const [schema, setSchema] = useState(initialSchema);
  const [activeField, setActiveField] = useState<FormField | null>(null);
  const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
  const [isSaving, setIsSaving] = useState(false);

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  );

  const addField = useCallback((type: FieldType) => {
    const newField: FormField = {
      id: randomUUID(),
      type,
      label: `New ${type.replace(/_/g, " ")} field`,
      required: false,
      hidden: false,
      ...(type === "select" || type === "radio" || type === "multi_select"
        ? { options: [{ value: "option-1", label: "Option 1" }] }
        : {}),
      ...(type === "rating" ? { maxRating: 5 } : {}),
    } as FormField;

    setSchema((prev) => ({
      ...prev,
      fields: [...prev.fields, newField],
      updatedAt: new Date().toISOString(),
    }));
    setSelectedFieldId(newField.id);
  }, []);

  const updateField = useCallback((fieldId: string, updates: Partial<FormField>) => {
    setSchema((prev) => ({
      ...prev,
      fields: prev.fields.map((f) =>
        f.id === fieldId ? ({ ...f, ...updates } as FormField) : f
      ),
      updatedAt: new Date().toISOString(),
    }));
  }, []);

  const removeField = useCallback((fieldId: string) => {
    setSchema((prev) => ({
      ...prev,
      fields: prev.fields.filter((f) => f.id !== fieldId),
      updatedAt: new Date().toISOString(),
    }));
    if (selectedFieldId === fieldId) setSelectedFieldId(null);
  }, [selectedFieldId]);

  function handleDragStart(event: DragStartEvent) {
    const field = schema.fields.find((f) => f.id === event.active.id);
    setActiveField(field ?? null);
  }

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    setActiveField(null);

    if (!over || active.id === over.id) return;

    setSchema((prev) => {
      const oldIndex = prev.fields.findIndex((f) => f.id === active.id);
      const newIndex = prev.fields.findIndex((f) => f.id === over.id);
      return {
        ...prev,
        fields: arrayMove(prev.fields, oldIndex, newIndex),
        updatedAt: new Date().toISOString(),
      };
    });
  }

  const handleSave = async () => {
    setIsSaving(true);
    try {
      await onSave({ ...schema, version: schema.version + 1 });
    } finally {
      setIsSaving(false);
    }
  };

  const selectedField = schema.fields.find((f) => f.id === selectedFieldId);

  return (
    <div className="flex gap-4 h-full">
      {/* Left: Field palette */}
      <div className="w-56 flex-shrink-0">
        <FieldPalette onAddField={addField} />
      </div>

      {/* Center: Form canvas */}
      <div className="flex-1 min-w-0">
        <div className="mb-4 flex items-center justify-between">
          <input
            value={schema.title}
            onChange={(e) => setSchema((prev) => ({ ...prev, title: e.target.value }))}
            className="text-xl font-semibold bg-transparent border-b border-transparent hover:border-gray-300 focus:border-blue-500 focus:outline-none px-1"
            placeholder="Form title"
          />
          <div className="flex gap-2">
            <button
              onClick={handleSave}
              disabled={isSaving}
              className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
            >
              {isSaving ? "Saving..." : "Save"}
            </button>
          </div>
        </div>

        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragStart={handleDragStart}
          onDragEnd={handleDragEnd}
        >
          <SortableContext
            items={schema.fields.map((f) => f.id)}
            strategy={verticalListSortingStrategy}
          >
            <div className="space-y-2">
              {schema.fields.map((field) => (
                <SortableFieldRow
                  key={field.id}
                  field={field}
                  isSelected={field.id === selectedFieldId}
                  onSelect={() => setSelectedFieldId(field.id)}
                  onRemove={() => removeField(field.id)}
                />
              ))}
            </div>
          </SortableContext>

          <DragOverlay>
            {activeField && (
              <div className="bg-white border-2 border-blue-400 rounded-lg p-4 shadow-xl opacity-90">
                <p className="font-medium text-sm">{activeField.label}</p>
                <p className="text-xs text-gray-400">{activeField.type}</p>
              </div>
            )}
          </DragOverlay>
        </DndContext>

        {schema.fields.length === 0 && (
          <div className="border-2 border-dashed border-gray-200 rounded-xl p-12 text-center">
            <p className="text-gray-400">Add fields from the panel on the left</p>
          </div>
        )}
      </div>

      {/* Right: Field editor panel */}
      <div className="w-72 flex-shrink-0">
        {selectedField ? (
          <FieldEditor
            field={selectedField}
            onChange={(updates) => updateField(selectedField.id, updates)}
          />
        ) : (
          <div className="p-4 text-sm text-gray-400">
            Select a field to edit its properties
          </div>
        )}
      </div>
    </div>
  );
}

Dynamic Form Renderer with Validation

// components/FormRenderer/FormRenderer.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useMemo } from "react";
import { FormSchema, FormField } from "@/types/form-schema";
import { DynamicField } from "./DynamicField";

function buildZodSchema(fields: FormField[]): z.ZodObject<any> {
  const shape: Record<string, z.ZodTypeAny> = {};

  for (const field of fields) {
    if (field.type === "section_header" || field.type === "divider") continue;

    let validator: z.ZodTypeAny = z.string();

    switch (field.type) {
      case "short_text":
      case "long_text": {
        const f = field as any;
        let v = z.string();
        if (f.minLength) v = v.min(f.minLength);
        if (f.maxLength) v = v.max(f.maxLength);
        if (f.pattern) v = v.regex(new RegExp(f.pattern));
        validator = v;
        break;
      }
      case "email":
        validator = z.string().email("Invalid email address");
        break;
      case "number": {
        const f = field as any;
        let v = z.coerce.number();
        if (f.min !== undefined) v = v.min(f.min);
        if (f.max !== undefined) v = v.max(f.max);
        validator = v;
        break;
      }
      case "select":
      case "radio":
        validator = z.string();
        break;
      case "multi_select":
        validator = z.array(z.string());
        break;
      case "checkbox":
        validator = z.boolean();
        break;
      case "rating":
        validator = z.coerce.number().min(1);
        break;
      case "date":
        validator = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
        break;
      default:
        validator = z.string();
    }

    if (!field.required) {
      validator = validator.optional();
    }

    shape[field.id] = validator;
  }

  return z.object(shape);
}

interface Props {
  schema: FormSchema;
  onSubmit: (data: Record<string, unknown>) => Promise<void>;
}

export function FormRenderer({ schema, onSubmit }: Props) {
  const zodSchema = useMemo(() => buildZodSchema(schema.fields), [schema.fields]);

  const {
    register,
    handleSubmit,
    watch,
    control,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(zodSchema),
  });

  const formValues = watch();

  // Evaluate conditional visibility
  function isFieldVisible(field: FormField): boolean {
    if (field.hidden) return false;
    const conditions = (field as any).conditions as Array<any> | undefined;
    if (!conditions?.length) return true;

    return conditions.every((rule) => {
      const watchedValue = formValues[rule.fieldId];
      switch (rule.operator) {
        case "equals":        return watchedValue === rule.value;
        case "not_equals":    return watchedValue !== rule.value;
        case "contains":      return String(watchedValue).includes(String(rule.value));
        case "greater_than":  return Number(watchedValue) > Number(rule.value);
        case "less_than":     return Number(watchedValue) < Number(rule.value);
        default:              return true;
      }
    });
  }

  const handleFormSubmit = async (data: Record<string, unknown>) => {
    // Strip hidden field values from submission
    const filtered = Object.fromEntries(
      Object.entries(data).filter(([key]) => {
        const field = schema.fields.find((f) => f.id === key);
        return field && isFieldVisible(field);
      })
    );
    await onSubmit(filtered);
  };

  return (
    <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
      {schema.description && (
        <p className="text-gray-600">{schema.description}</p>
      )}

      {schema.fields.map((field) => {
        if (!isFieldVisible(field)) return null;

        if (field.type === "section_header") {
          return (
            <div key={field.id} className="pt-4">
              <h3 className="text-lg font-semibold text-gray-900">{field.label}</h3>
              {field.description && (
                <p className="text-sm text-gray-500 mt-1">{field.description}</p>
              )}
            </div>
          );
        }

        if (field.type === "divider") {
          return <hr key={field.id} className="border-gray-200" />;
        }

        return (
          <DynamicField
            key={field.id}
            field={field}
            register={register}
            control={control}
            error={errors[field.id]}
          />
        );
      })}

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full py-3 px-6 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
      >
        {isSubmitting ? "Submitting..." : schema.submitButtonLabel ?? "Submit"}
      </button>
    </form>
  );
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Dynamic Field Component

// components/FormRenderer/DynamicField.tsx
"use client";

import { Controller, Control, FieldError, UseFormRegister } from "react-hook-form";
import { FormField } from "@/types/form-schema";
import { StarRating } from "./StarRating";

interface Props {
  field: FormField;
  register: UseFormRegister<any>;
  control: Control<any>;
  error?: FieldError;
}

export function DynamicField({ field, register, control, error }: Props) {
  const errorMessage = error?.message as string | undefined;

  const labelEl = (
    <label className="block text-sm font-medium text-gray-700 mb-1">
      {field.label}
      {field.required && <span className="text-red-500 ml-1">*</span>}
    </label>
  );

  const descriptionEl = field.description && (
    <p className="text-xs text-gray-500 mt-1">{field.description}</p>
  );

  const errorEl = errorMessage && (
    <p className="text-xs text-red-500 mt-1">{errorMessage}</p>
  );

  const inputClass = `w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
    errorMessage ? "border-red-400" : "border-gray-300"
  }`;

  switch (field.type) {
    case "short_text":
    case "email":
    case "phone":
    case "url":
      return (
        <div>
          {labelEl}
          <input
            {...register(field.id)}
            type={field.type === "email" ? "email" : field.type === "url" ? "url" : "text"}
            placeholder={(field as any).placeholder}
            className={inputClass}
          />
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "long_text":
      return (
        <div>
          {labelEl}
          <textarea
            {...register(field.id)}
            placeholder={(field as any).placeholder}
            rows={4}
            className={inputClass}
          />
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "number":
      return (
        <div>
          {labelEl}
          <div className="relative">
            <input
              {...register(field.id)}
              type="number"
              min={(field as any).min}
              max={(field as any).max}
              step={(field as any).step}
              className={inputClass}
            />
            {(field as any).unit && (
              <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">
                {(field as any).unit}
              </span>
            )}
          </div>
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "select":
      return (
        <div>
          {labelEl}
          <select {...register(field.id)} className={inputClass}>
            <option value="">Select an option...</option>
            {(field as any).options?.map((opt: any) => (
              <option key={opt.value} value={opt.value}>{opt.label}</option>
            ))}
          </select>
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "radio":
      return (
        <div>
          {labelEl}
          <div className="space-y-2 mt-1">
            {(field as any).options?.map((opt: any) => (
              <label key={opt.value} className="flex items-center gap-2 text-sm cursor-pointer">
                <input {...register(field.id)} type="radio" value={opt.value} className="text-blue-600" />
                {opt.label}
              </label>
            ))}
          </div>
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "checkbox":
      return (
        <div>
          <label className="flex items-start gap-2 cursor-pointer">
            <input {...register(field.id)} type="checkbox" className="mt-0.5 text-blue-600" />
            <span className="text-sm font-medium text-gray-700">
              {field.label}
              {field.required && <span className="text-red-500 ml-1">*</span>}
            </span>
          </label>
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "rating":
      return (
        <div>
          {labelEl}
          <Controller
            name={field.id}
            control={control}
            render={({ field: controllerField }) => (
              <StarRating
                value={controllerField.value ?? 0}
                max={(field as any).maxRating ?? 5}
                onChange={controllerField.onChange}
                labels={(field as any).labels}
              />
            )}
          />
          {descriptionEl}
          {errorEl}
        </div>
      );

    case "date":
      return (
        <div>
          {labelEl}
          <input {...register(field.id)} type="date" className={inputClass} />
          {descriptionEl}
          {errorEl}
        </div>
      );

    default:
      return null;
  }
}

Response Storage API

// app/api/forms/[formId]/responses/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ formId: string }> }
) {
  const { formId } = await params;
  const body = await req.json();

  const form = await db.form.findFirst({
    where: { id: formId, status: "published" },
  });

  if (!form) {
    return NextResponse.json({ error: "Form not found" }, { status: 404 });
  }

  // Server-side schema validation
  const schema = form.schema as FormSchema;
  // Build and run Zod validation on server (same logic as renderer)
  const zodSchema = buildZodSchema(schema.fields);
  const parsed = zodSchema.safeParse(body.data);

  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const response = await db.$transaction([
    db.formResponse.create({
      data: {
        formId,
        data: parsed.data,
        respondentEmail: body.email,
        metadata: {
          userAgent: req.headers.get("user-agent"),
          ip: req.headers.get("x-forwarded-for"),
          submittedAt: new Date().toISOString(),
        },
      },
    }),
    db.form.update({
      where: { id: formId },
      data: { responseCount: { increment: 1 } },
    }),
  ]);

  return NextResponse.json(
    { id: response[0].id, message: form.schema?.successMessage ?? "Response submitted successfully." },
    { status: 201 }
  );
}

Cost and Timeline Estimates

ComponentTimelineCost (USD)
Form schema design + DB1 day$600–$1,000
Form builder editor (drag-and-drop)3–5 days$2,400–$4,000
Dynamic renderer with Zod validation2–3 days$1,600–$2,500
Conditional logic1–2 days$800–$1,600
Response storage + export1–2 days$800–$1,600
Complete form builder2–3 weeks$8,000–$15,000

See Also


Working With Viprasol

We build form builder systems for SaaS platformsβ€”from simple survey tools through complex multi-step intake workflows with conditional branching and webhook delivery. Our team has shipped form builders handling thousands of submissions per day.

What we deliver:

  • Schema-driven form builder with drag-and-drop field ordering
  • Dynamic renderer with server-side and client-side validation
  • Conditional field logic with AND/OR operators
  • Response analytics and CSV/Excel export
  • Webhook delivery for form responses

Explore our web development services or contact us to discuss your form builder requirements.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.