A form builder lets non-technical users create custom forms without code. Here is how to build one with drag-and-drop.
Field Types
// types/form-builder.ts
export type FieldType =
| "text"
| "email"
| "number"
| "textarea"
| "select"
| "checkbox"
| "radio"
| "date"
| "file";
export interface FieldOption {
label: string;
value: string;
}
export interface ValidationRule {
type: "required" | "minLength" | "maxLength" | "pattern" | "min" | "max";
value?: string | number;
message: string;
}
export interface FormField {
id: string;
type: FieldType;
label: string;
placeholder?: string;
helpText?: string;
options?: FieldOption[];
validation: ValidationRule[];
defaultValue?: string;
width?: "full" | "half";
}
export interface FormSchema {
id: string;
title: string;
description?: string;
fields: FormField[];
submitLabel?: string;
successMessage?: string;
}
Form Builder Component
"use client";
import { useState, useCallback } from "react";
import type { FormField, FormSchema, FieldType } from "@/types/form-builder";
import { FieldEditor } from "./FieldEditor";
const FIELD_PALETTE: { type: FieldType; label: string; icon: string }[] = [
{ type: "text", label: "Text Input", icon: "T" },
{ type: "email", label: "Email", icon: "@" },
{ type: "number", label: "Number", icon: "#" },
{ type: "textarea", label: "Text Area", icon: "P" },
{ type: "select", label: "Dropdown", icon: "V" },
{ type: "checkbox", label: "Checkbox", icon: "C" },
{ type: "radio", label: "Radio Group", icon: "O" },
{ type: "date", label: "Date", icon: "D" },
{ type: "file", label: "File Upload", icon: "F" },
];
interface FormBuilderProps {
initial?: FormSchema;
onSave: (schema: FormSchema) => void;
}
export function FormBuilder({ initial, onSave }: FormBuilderProps) {
const [schema, setSchema] = useState<FormSchema>(
initial ?? {
id: crypto.randomUUID(),
title: "Untitled Form",
fields: [],
submitLabel: "Submit",
}
);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const addField = useCallback((type: FieldType) => {
const newField: FormField = {
id: crypto.randomUUID(),
type,
label: `New ${type} field`,
validation: [],
width: "full",
...(["select", "radio"].includes(type)
? { options: [{ label: "Option 1", value: "option-1" }] }
: {}),
};
setSchema((prev) => ({
...prev,
fields: [...prev.fields, newField],
}));
setSelectedFieldId(newField.id);
}, []);
const updateField = useCallback((id: string, updates: Partial<FormField>) => {
setSchema((prev) => ({
...prev,
fields: prev.fields.map((f) => (f.id === id ? { ...f, ...updates } : f)),
}));
}, []);
const removeField = useCallback((id: string) => {
setSchema((prev) => ({
...prev,
fields: prev.fields.filter((f) => f.id !== id),
}));
setSelectedFieldId(null);
}, []);
const moveField = useCallback((fromIndex: number, toIndex: number) => {
setSchema((prev) => {
const fields = [...prev.fields];
const [moved] = fields.splice(fromIndex, 1);
fields.splice(toIndex, 0, moved);
return { ...prev, fields };
});
}, []);
const selectedField = schema.fields.find((f) => f.id === selectedFieldId);
return (
<div className="flex h-[calc(100vh-64px)]">
{/* Left: Field Palette */}
<div className="w-56 border-r bg-muted/30 p-3 overflow-y-auto">
<h3 className="text-xs font-semibold uppercase text-muted-foreground mb-3">
Fields
</h3>
<div className="space-y-1">
{FIELD_PALETTE.map(({ type, label, icon }) => (
<button
key={type}
onClick={() => addField(type)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded hover:bg-muted text-left"
>
<span className="w-6 h-6 bg-muted rounded flex items-center justify-center text-xs font-mono">
{icon}
</span>
{label}
</button>
))}
</div>
</div>
{/* Center: Form Canvas */}
<div className="flex-1 p-6 overflow-y-auto">
<div className="max-w-xl mx-auto">
<input
value={schema.title}
onChange={(e) =>
setSchema((prev) => ({ ...prev, title: e.target.value }))
}
className="text-2xl font-bold w-full bg-transparent border-none outline-none mb-1"
placeholder="Form title"
/>
<input
value={schema.description ?? ""}
onChange={(e) =>
setSchema((prev) => ({ ...prev, description: e.target.value }))
}
className="text-sm text-muted-foreground w-full bg-transparent border-none outline-none mb-6"
placeholder="Form description (optional)"
/>
{schema.fields.length === 0 ? (
<div className="border-2 border-dashed rounded-lg p-12 text-center text-muted-foreground">
<p className="text-sm">
Click a field type on the left to add it to your form
</p>
</div>
) : (
<div className="space-y-3">
{schema.fields.map((field, index) => (
<div
key={field.id}
draggable
onDragStart={(e) => e.dataTransfer.setData("fieldIndex", String(index))}
onDragOver={(e) => { e.preventDefault(); setDragOverIndex(index); }}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(e) => {
e.preventDefault();
const from = Number(e.dataTransfer.getData("fieldIndex"));
moveField(from, index);
setDragOverIndex(null);
}}
onClick={() => setSelectedFieldId(field.id)}
className={`
border rounded-lg p-3 cursor-pointer transition-all
${selectedFieldId === field.id ? "ring-2 ring-primary border-primary" : "hover:border-foreground/20"}
${dragOverIndex === index ? "border-primary border-dashed" : ""}
`}
>
<FieldPreview field={field} />
</div>
))}
</div>
)}
<div className="mt-6 flex gap-2">
<button
onClick={() => onSave(schema)}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm"
>
Save Form
</button>
</div>
</div>
</div>
{/* Right: Field Settings */}
{selectedField && (
<div className="w-72 border-l bg-muted/30 p-4 overflow-y-auto">
<FieldEditor
field={selectedField}
onUpdate={(updates) => updateField(selectedField.id, updates)}
onRemove={() => removeField(selectedField.id)}
/>
</div>
)}
</div>
);
}
Field Preview
function FieldPreview({ field }: { field: FormField }) {
const isRequired = field.validation.some((v) => v.type === "required");
return (
<div>
<label className="text-sm font-medium">
{field.label}
{isRequired && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.helpText && (
<p className="text-xs text-muted-foreground mt-0.5">{field.helpText}</p>
)}
<div className="mt-1.5 pointer-events-none">
{field.type === "textarea" ? (
<div className="h-16 border rounded bg-background px-3 py-2 text-sm text-muted-foreground">
{field.placeholder ?? ""}
</div>
) : field.type === "select" ? (
<div className="border rounded bg-background px-3 py-2 text-sm text-muted-foreground">
{field.options?.[0]?.label ?? "Select..."}
</div>
) : field.type === "checkbox" ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border rounded" />
<span className="text-sm">{field.options?.[0]?.label ?? "Checkbox"}</span>
</div>
) : field.type === "radio" ? (
<div className="space-y-1">
{(field.options ?? []).map((opt) => (
<div key={opt.value} className="flex items-center gap-2">
<div className="w-4 h-4 border rounded-full" />
<span className="text-sm">{opt.label}</span>
</div>
))}
</div>
) : (
<div className="border rounded bg-background px-3 py-2 text-sm text-muted-foreground">
{field.placeholder ?? ""}
</div>
)}
</div>
</div>
);
}
Form Renderer
"use client";
import { useForm } from "react-hook-form";
import type { FormSchema } from "@/types/form-builder";
export function FormRenderer({
schema,
onSubmit,
}: {
schema: FormSchema;
onSubmit: (data: Record<string, unknown>) => void;
}) {
const form = useForm();
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="max-w-xl mx-auto space-y-4"
>
<h1 className="text-2xl font-bold">{schema.title}</h1>
{schema.description && (
<p className="text-muted-foreground">{schema.description}</p>
)}
{schema.fields.map((field) => {
const isRequired = field.validation.some((v) => v.type === "required");
return (
<div key={field.id}>
<label htmlFor={field.id} className="text-sm font-medium block mb-1">
{field.label}
{isRequired && <span className="text-red-500 ml-0.5">*</span>}
</label>
{renderField(field, form.register)}
{field.helpText && (
<p className="text-xs text-muted-foreground mt-1">{field.helpText}</p>
)}
</div>
);
})}
<button
type="submit"
className="bg-primary text-primary-foreground px-4 py-2 rounded"
>
{schema.submitLabel ?? "Submit"}
</button>
</form>
);
}
function renderField(field: FormField, register: any) {
const validation: Record<string, unknown> = {};
for (const rule of field.validation) {
if (rule.type === "required") validation.required = rule.message;
if (rule.type === "minLength") validation.minLength = { value: rule.value, message: rule.message };
if (rule.type === "maxLength") validation.maxLength = { value: rule.value, message: rule.message };
if (rule.type === "pattern") validation.pattern = { value: new RegExp(rule.value as string), message: rule.message };
}
switch (field.type) {
case "textarea":
return (
<textarea
id={field.id}
placeholder={field.placeholder}
className="w-full border rounded px-3 py-2 text-sm"
rows={4}
{...register(field.id, validation)}
/>
);
case "select":
return (
<select id={field.id} className="w-full border rounded px-3 py-2 text-sm" {...register(field.id, validation)}>
<option value="">Select...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
case "checkbox":
return (
<div className="flex items-center gap-2">
<input type="checkbox" id={field.id} {...register(field.id, validation)} />
</div>
);
case "radio":
return (
<div className="space-y-1">
{field.options?.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 text-sm">
<input type="radio" value={opt.value} {...register(field.id, validation)} />
{opt.label}
</label>
))}
</div>
);
default:
return (
<input
id={field.id}
type={field.type}
placeholder={field.placeholder}
className="w-full border rounded px-3 py-2 text-sm"
{...register(field.id, validation)}
/>
);
}
}
Need Custom Forms for Your Business?
We build dynamic form systems and data collection tools. Contact us to learn more.