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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Form schema design + DB | 1 day | $600β$1,000 |
| Form builder editor (drag-and-drop) | 3β5 days | $2,400β$4,000 |
| Dynamic renderer with Zod validation | 2β3 days | $1,600β$2,500 |
| Conditional logic | 1β2 days | $800β$1,600 |
| Response storage + export | 1β2 days | $800β$1,600 |
| Complete form builder | 2β3 weeks | $8,000β$15,000 |
See Also
- React Hook Form + Zod β Form handling without a builder
- React Drag-and-Drop dnd-kit β The drag-and-drop primitives used here
- TypeScript Branded Types β Type-safe field IDs and schemas
- PostgreSQL JSONB Patterns β Querying form response data
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.
About the Author
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.
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
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.