How We Built a Drag-and-Drop Form Builder in Svelte 5
Why Build a Form Builder?
Every SvelteKit project needs forms. Every form needs a Zod schema, a server action, and a client component. That’s three files of boilerplate per form, and the code is almost identical each time. We built SvelteForms to eliminate that repetition — drag fields onto a canvas, configure validation visually, export production-ready code.
This post covers the technical decisions behind building a drag-and-drop form builder in Svelte 5, including state management with runes, the HTML5 Drag and Drop API, and real-time code generation.
Architecture: Three Columns, One State
The builder uses a classic three-column layout: field palette (left), canvas (center), configuration panel (right). All three panels read from and write to a single reactive state store.
class BuilderStore {
fields = $state([]);
selectedFieldId = $state(null);
settings = $state({ name: 'my-form', layout: 'vertical' });
selectedField = $derived(
this.fields.find(f => f.id === this.selectedFieldId) ?? null
);
addField(type, index) {
const field = createField(type);
this.fields.splice(index ?? this.fields.length, 0, field);
this.selectedFieldId = field.id;
}
updateField(id, updates) {
const i = this.fields.findIndex(f => f.id === id);
if (i >= 0) this.fields[i] = { ...this.fields[i], ...updates };
}
} Why Svelte 5 Runes Over Stores
Svelte 4 would have used writable stores for this. Svelte 5 runes ($state, $derived) are better for a class-based state model because:
- The state and the methods that mutate it live in the same class — no separate action functions
$derivedreplaces manual subscriptions for computed values likeselectedField- Array mutations (splice, push) are tracked automatically — no need to reassign the whole array
- The class is a singleton module export, so all components import the same instance
HTML5 Drag and Drop: Simpler Than Libraries
We initially considered svelte-dnd-action and other DnD libraries. We ended up using the native HTML5 Drag and Drop API because:
- It handles both “drag from palette” (new field) and “reorder on canvas” with the same API
- No framework-specific library needed — works with Svelte 5 event handlers directly
- The dataTransfer API lets us distinguish between field creation and reordering using custom MIME types
Custom MIME Types for Intent
The key insight: use dataTransfer.setData() with custom MIME types to encode what’s being dragged.
// Palette: dragging a new field type
function handlePaletteDragStart(e, fieldType) {
e.dataTransfer.setData('application/svelteforms-field-type', fieldType);
e.dataTransfer.effectAllowed = 'copy';
}
// Canvas: reordering an existing field
function handleCanvasDragStart(e, index) {
e.dataTransfer.setData('application/svelteforms-reorder', String(index));
e.dataTransfer.effectAllowed = 'move';
}
// Drop handler checks which type of drag
function handleDrop(e) {
const fieldType = e.dataTransfer.getData('application/svelteforms-field-type');
const reorderFrom = e.dataTransfer.getData('application/svelteforms-reorder');
if (fieldType) {
builderStore.addField(fieldType, dropIndex);
} else if (reorderFrom) {
builderStore.moveField(parseInt(reorderFrom), dropIndex);
}
} This pattern avoids the “is this a new field or a reorder?” ambiguity that plagues many builder implementations. The browser handles the distinction at the protocol level.
Real-Time Code Generation
The most satisfying part of SvelteForms is clicking “Export” and seeing production-ready code. The generators are pure functions that take the field array and settings, and return strings.
Zod Schema Generation
Each field type maps to a Zod type with chained validators:
function generateFieldSchema(field) {
switch (field.type) {
case 'text':
case 'email':
case 'password':
let chain = 'z.string()';
if (field.type === 'email') chain += ".email('Invalid email')";
for (const rule of field.validation) {
if (rule.type === 'minLength') chain += ".min(" + rule.value + ", '" + rule.message + "')";
if (rule.type === 'maxLength') chain += ".max(" + rule.value + ", '" + rule.message + "')";
}
if (!isRequired(field)) chain += '.optional()';
return chain;
case 'number':
return 'z.coerce.number()' + numberValidators(field);
case 'checkbox':
return 'z.boolean().default(false)';
case 'select':
case 'radio-group':
return "z.enum([" + field.options.map(o => "'" + o.value + "'").join(', ') + "])";
}
} The generator produces code that passes TypeScript compilation without modification. We test this with a suite of 16 unit tests that verify the output for every field type and validation combination.
13 Field Types: The Minimum Viable Set
We spent a week debating field types before landing on 13. The criteria: anything less feels thin (no file upload? no radio groups?), anything more delays launch (rich text editor? signature pad? date range picker?).
The final list: text, email, password, number, textarea, select, radio group, checkbox, checkbox group, date, file upload, hidden, and section divider. Each has its own Zod mapping, canvas preview, and configuration panel.
Lessons Learned
1. Ship the vertical slice first
We built one field type (text input) end-to-end before touching the other 12: drag from palette, render on canvas, configure in panel, preview live, export code. Once that slice worked, adding field types was mechanical repetition.
2. Generated code quality is everything
If the exported code looks amateur — inconsistent indentation, missing error handling, non-idiomatic patterns — developers won’t trust the tool. We spent more time on output formatting than on the builder UI.
3. localStorage is fine for v1
We debated cloud save, user accounts, and collaborative editing. All of it was scope creep. localStorage with auto-save every 5 seconds covers the use case: developers building a form, exporting it, and moving on. Undo/redo with a 20-snapshot history stack handles the “oops” case.
Try It
The form builder is free, runs entirely in your browser, and requires no account. Start from a template or drag fields from scratch. See the code examples to preview what gets generated, or read the integration guide for setup instructions.
Build forms faster
Try the form builder →