Forms in Vue 3
Forms that get generated from configuration instead of being hard-coded save development time and let back-end teams change form structures without a frontend deployment. This article shows how to build a robust form builder in Vue 3, from field definitions through dynamic validation to conditional fields.
Table of Contents
- 1. The concept of configuration-driven forms
- 2. Field definitions as JSON schema
- 3. Dynamic field components with component :is
- 4. v-model handling in the form builder
- 5. Conditional fields and dependencies
- 6. Dynamic validation rules
- 7. Array fields and repeater groups
- 8. Loading the form schema from the server
- 9. Comparison: form builder approaches
- 10. Summary
- 11. FAQ
1. The concept of configuration-driven forms
A form builder in Vue 3 moves the form structure out of the template and into a data configuration. Instead of writing a new form template for every use case, you define the fields, their types, labels, default values, and validation rules in a JavaScript object or JSON document. The Vue component reads this configuration and renders the corresponding form from it, fully reactive and without hard-coded fields in the template.
The decisive advantage of this approach lies in scalability. A back-end team can ship new form fields through an API without frontend developers having to touch the template. A CMS can configure form structures for landing pages. A/B tests can ship different field arrangements or types without code deployments. For applications with many form variants, such as checkout forms with country-specific fields or product configuration forms, the form builder approach in Vue 3 is considerably more efficient than static templates.
2. Field definitions as JSON schema
The foundation of every dynamic form in Vue 3 is a clearly defined schema for field definitions. Every field definition is an object with at least the properties type (e.g. "text", "select", "checkbox"), name (a unique field identifier for the form model), label (display name), and optionally placeholder, defaultValue, rules (validation rules), and options (for select/radio). For conditional fields, showIf gets added as a callback or configuration object that references other field values.
An important design principle for the form builder: the schema should be JSON-serializable so it can be delivered from the server. This rules out functions as values for callback-based conditions; instead, conditions are defined as declarative objects that the form builder interprets. The schema format should also be extensible: new field types get added by registering new field components, without changing the schema format itself. This approach keeps the dynamic form system in Vue 3 maintainable in the long run.
// form-schema.js - field definitions for a dynamic Vue 3 form
export const registrationFormSchema = [
{
type: 'text',
name: 'firstName',
label: 'Vorname',
placeholder: 'Max',
rules: { required: true, minLength: 2 },
},
{
type: 'select',
name: 'country',
label: 'Land',
defaultValue: 'DE',
options: [
{ value: 'DE', label: 'Deutschland' },
{ value: 'AT', label: 'Österreich' },
{ value: 'CH', label: 'Schweiz' },
],
rules: { required: true },
},
{
type: 'text',
name: 'vatId',
label: 'USt-IdNr.',
// Conditional: only show when country is AT or CH
showIf: { field: 'country', operator: 'in', value: ['AT', 'CH'] },
rules: { required: false, pattern: '^[A-Z]{2}[0-9A-Z]+$' },
},
{
type: 'checkbox',
name: 'newsletter',
label: 'Newsletter abonnieren',
defaultValue: false,
},
]
3. Dynamic field components with component :is
The centerpiece of the form builder in Vue 3 is the component :is directive, which lets you decide at runtime which component gets rendered for a field. You register all field types in a map that points from the field type string to the corresponding Vue component. The form builder iterates over the schema and renders the matching component for every field definition via :is="fieldComponents[field.type]". Unknown types can be mapped to a generic fallback component that shows a warning.
Every field component receives the field definition as a prop and implements v-model via defineModel() for seamless two-way data binding. The design of this interface is critical for extensibility: anyone who wants to add a new field type only needs to create a new component and register it in the map, without touching the form builder itself. This open/closed principle turns the dynamic form builder into a real architectural component, not just a template trick.
4. v-model handling in the form builder
The v-model handling in the form builder must manage the entire form state as a reactive object that holds a key for every field in the schema. On first render, the model is initialized from the schema's defaultValue entries. Fields that don't define a defaultValue are pre-populated with undefined or the type-specific default. The form builder forwards changes from field components via update:modelValue events to the parent form model and emits the whole updated model upward.
In Vue 3.4+, defineModel() simplifies this pattern considerably. Field components define their own model with const value = defineModel() and can work with it directly, without manual prop/emit boilerplate. The form builder itself uses an internal reactive() object as its state store and propagates changes upward in batches. This pattern matters for forms with hundreds of fields, where many small emit('update:modelValue') calls could turn into a performance problem.
<!-- FormBuilder.vue - dynamic form renderer with component :is -->
<template>
<form @submit.prevent="handleSubmit">
<template v-for="field in visibleFields" :key="field.name">
<div class="form-field-wrapper">
<label :for="field.name">{{ field.label }}</label>
<component
:is="fieldComponents[field.type] ?? FallbackField"
:id="field.name"
:field="field"
v-model="formData[field.name]"
:error="errors[field.name]"
/>
<span v-if="errors[field.name]" class="field-error">
{{ errors[field.name] }}
</span>
</div>
</template>
<button type="submit" :disabled="isSubmitting">Absenden</button>
</form>
</template>
<script setup>
import { reactive, computed } from 'vue'
import TextInput from './fields/TextInput.vue'
import SelectField from './fields/SelectField.vue'
import CheckboxField from './fields/CheckboxField.vue'
import FallbackField from './fields/FallbackField.vue'
const props = defineProps({ schema: Array, modelValue: Object })
const emit = defineEmits(['update:modelValue', 'submit'])
// Map field type strings to components
const fieldComponents = { text: TextInput, select: SelectField, checkbox: CheckboxField }
// Initialize reactive form data from schema defaults
const formData = reactive(
Object.fromEntries(props.schema.map((f) => [f.name, f.defaultValue ?? null]))
)
// Evaluate showIf conditions to filter visible fields
const visibleFields = computed(() =>
props.schema.filter((field) => {
if (!field.showIf) return true
const { field: depField, operator, value } = field.showIf
const depValue = formData[depField]
if (operator === 'in') return Array.isArray(value) && value.includes(depValue)
if (operator === 'eq') return depValue === value
return true
})
)
<\/script>
5. Conditional fields and dependencies
Conditional fields are the point where a form builder in Vue 3 shows its full value. Instead of checking hard-coded conditions with v-if in the template, the form builder evaluates the showIf configuration of every field and hides fields that don't apply. When a field is hidden, its value must be removed or reset from the form model so it doesn't get submitted by mistake. This reset-on-hide mechanism is a frequently overlooked detail that leads to hard-to-debug validation errors in production forms.
More complex dependencies between fields, such as "field B is required when field A holds more than 1000", can be modeled through an extended showIf format that combines several conditions with and or or. In Vue 3, you handle this with computed() and a recursive condition evaluation. The pattern remains fully declarative: the dynamic form builder evaluates the conditions without the field components needing to know about each other.
6. Dynamic validation rules
Validation in a form builder must also be configuration-driven. The validation rules in the schema, such as required, minLength, maxLength, pattern, min, max, get evaluated by a validation engine that maintains a map from rule name to validation function. The result is an error object with the field name as key and the error message as value, which gets routed reactively into the template. Every field component receives the corresponding error string as a prop and displays it below the field.
Async validation rules, such as "is this email address already registered?", require extra care: they should only trigger on blur, not on every keystroke, and must be debounced to avoid unnecessary API requests. Combined with VeeValidate or Zod, the validation rules from the schema can be translated into type-safe validation schemas that keep frontend and backend validation consistent. This approach makes the dynamic form system in Vue 3 production-ready.
7. Array fields and repeater groups
Many forms contain repeater groups, such as "add contact persons" or "line items in an order". A form builder in Vue 3 needs to support this pattern without requiring a separate implementation for every array field type. The approach: an ArrayField component renders a list of sub-forms, each with the same field configuration, and provides buttons for adding and removing entries. Every entry in the array has its own reactive state and its own validation.
The schema for an array field additionally contains the properties type: "array", minItems, maxItems, and itemSchema, which in turn holds a complete form schema for each entry. This nesting can theoretically go arbitrarily deep, but in practice two levels are enough for most use cases. Important: when removing an entry, all validation errors for that entry must be cleared too, so no orphaned errors remain in the error state.
// composables/useFormValidation.js - dynamic validation engine
import { reactive } from 'vue'
const validators = {
required: (value) => (value === null || value === '' || value === undefined)
? 'Dieses Feld ist erforderlich.' : null,
minLength: (value, min) => value && value.length < min
? `Mindestens ${min} Zeichen erforderlich.` : null,
maxLength: (value, max) => value && value.length > max
? `Maximal ${max} Zeichen erlaubt.` : null,
pattern: (value, regex) => value && !new RegExp(regex).test(value)
? 'Das Format ist ungültig.' : null,
min: (value, min) => value !== null && Number(value) < min
? `Mindestwert: ${min}.` : null,
max: (value, max) => value !== null && Number(value) > max
? `Maximalwert: ${max}.` : null,
}
export function useFormValidation(schema, formData) {
const errors = reactive({})
function validateField(field) {
if (!field.rules) return null
for (const [rule, ruleValue] of Object.entries(field.rules)) {
const fn = validators[rule]
if (!fn) continue
const error = fn(formData[field.name], ruleValue)
if (error) {
errors[field.name] = error
return error
}
}
delete errors[field.name]
return null
}
function validateAll(visibleFields) {
let valid = true
for (const field of visibleFields) {
if (validateField(field)) valid = false
}
return valid
}
return { errors, validateField, validateAll }
}
8. Loading the form schema from the server
A form builder that only reads its schema from static JavaScript files doesn't use its full potential. The next step is loading the schema dynamically from an API, so back-end developers or content managers can change form structures without a frontend deployment. In Nuxt 3, you load the schema with useFetch('/api/forms/registration') and then pass it to the form builder. The form re-renders automatically whenever the schema changes, since everything is reactive.
Server-side rendering raises an important point: the schema must already be available on the first render, so search engines can index the form and no layout shift occurs during hydration. With useAsyncData and server-side rendering, this is handled automatically in Nuxt 3. Another important detail: for security reasons, the server schema must be validated on the server before it gets delivered to the client. A schema that strips validation rules for sensitive fields or smuggles in malicious field types would be a security risk for the dynamic form system.
9. Comparison: form builder approaches in Vue 3
There are several strategies for implementing dynamic forms in Vue 3. The choice depends on the requirements around flexibility, type safety, and complexity. Libraries like Formkit, VeeValidate, and VueForms offer their own form-builder concepts that differ in approach and target audience.
| Approach | Strengths | Weaknesses | Recommended for |
|---|---|---|---|
| Custom form builder | Full control, no dependencies | More implementation effort | Custom field types & design systems |
| Formkit | Extensive field types, good DX | Larger bundle, own conventions | Fast delivery with standard fields |
| VeeValidate + schema | Zod/Yup integration, type-safe | Validation-focused, no UI | Complex validation requirements |
| JSON-schema-based | Server-configurable, standardized | Limited dynamic logic | CMS-driven forms |
| Headless + Tailwind | Full style control, lean | No prebuilt behavior | Design system integration |
For most projects, a hybrid approach is recommended: a lightweight custom form builder for the rendering logic, combined with VeeValidate or Zod for validation. That gives full control over rendering and the UI, while using mature libraries for the most complex part, the validation logic. This keeps the dynamic form system in Vue 3 maintainable without giving up proven tools.
Mironsoft
Vue 3 development, form builders, and complex form systems
Dynamic forms that back-end teams can configure themselves?
We build configuration-driven form builders in Vue 3, with JSON schema, dynamic validation, conditional fields, and seamless API integration, so form structures can be changed without a frontend deployment.
Schema design
JSON-schema-based field definitions delivered from the server and validated securely
Validation logic
Dynamic validation rules with Zod integration and async validation for API checks
Design system
Field components in your own Tailwind design system, fully accessibility-compliant
10. Summary
A form builder in Vue 3 isn't overkill, it's an investment in flexibility and maintainability. With component :is for dynamic field components, a clear JSON schema for field definitions, and a configurable validation engine, you get dynamic forms that can be adapted without code changes. Conditional fields through declarative showIf configurations, reset-on-hide for non-visible fields, and array fields for repeater groups cover the most common requirements.
Loading the schema from the server opens the door to CMS-driven forms and back-end-side configuration. Combined with VeeValidate or Zod for validation and a clear field component API, the system stays extensible: new field types arrive through new components, not through changes to the form builder itself. This approach scales from simple contact forms to complex multi-step checkout forms with country-specific fields, without a rewrite.
Form Builder in Vue 3 - the essentials at a glance
Core mechanism
component :is with a field-type-to-component map dynamically renders the correct field component, extensible without changes to the form builder.
Conditional fields
Declarative showIf configuration in the field definition, with reset-on-hide so hidden fields don't get submitted by mistake.
Validation
Rule-based validation engine that evaluates schema rules. Zod integration for type-safe, reusable validation logic.
Server schema
Load the schema via useFetch from the server, CMS-driven forms without a frontend deployment. Validate the schema server-side.