Forms are an integral part of web applications, but building a dynamic form—where form fields are generated and managed dynamically—can be challenging.
In this article, we’ll explore how to create a dynamic form in Next.js using objects, manage its state, and handle form submission effectively.
We will leverage TypeScript to ensure type safety, providing a robust solution for developers working with modern JavaScript frameworks.
What Are Dynamic Forms ?
Dynamic forms allow developers to add or remove form fields dynamically based on user input or data. Instead of hardcoding each form field, we use objects to define the form structure and manage the dynamic addition of fields. This guide will show you how to build a dynamic form in Next.js using objects while leveraging Tailwind CSS for styling.
This approach is highly flexible, especially for applications that need customizable input fields, such as :
• Surveys or Quizzes : Adding dynamic questions and answers.
• Customizable User Forms : Allowing users to input different kinds of data.
• Product Management Systems : Managing attributes like specifications or tags dynamically.
Prerequisites :
To follow along, ensure you have :
1. Next.js installed.
2. Basic knowledge of TypeScript.
3. Dynamically generated form fields based on a configuration object (fields).
4. Validation integrated with Formik using a validationType string.
5. Supports different input types: text, select, checkbox, file, etc.
6. Allows attaching event handlers, such as onOptionChange.
7. Familiarity with libraries like Formik and Yup for form handling and validation.
Implementation Overview :
We’ll create a dynamic form to manage multiple objects with properties such as title and description. This will involve:
1. Managing the form’s state dynamically.
2. Adding and removing fields dynamically.
3. Validating the form using Yup.
4. Submitting the data and handling it efficiently.
Step 1 : Dynamic Form Component
Below is the dynamic form implementation styled with Tailwind CSS.
‘use client’
import React, { useRef, useState } from ‘react’
import { Formik, Form, Field, FieldInputProps, FormikProps, getIn } from ‘formik’
import * as Yup from ‘yup’
import { Button, Checkbox, FileInput, FloatingLabel, Label, Radio, RangeSlider, Select, Textarea,ToggleSwitch } from ‘flowbite-react’
import LazyDropdown from ‘../ui-components/LazyDropdown’
import { FormField } from ‘@/app/(DashboardLayout)/types/apps/formField’
import { IconEye, IconEyeOff } from ‘@tabler/icons-react’
import styles from ‘./../../css/pages/DynamicForm.module.css’
import { Icon } from ‘@iconify/react’
import { Constants } from ‘@/utils/constants’
import { getUser } from ‘@/app/api/config/ConfigData’
import { useToast } from ‘@/app/context/ToastContext’
import i18n from ‘@/utils/i18n’
import { isRegExp } from ‘lodash’
import { DynamicFormProps } from ‘@/app/types/GlobalType’
const parseValidation = ( validationType: string, validationMessage?: string) => {
const rules = validationType.split(‘|’)
let schema: Yup.MixedSchema<any, any> = Yup.mixed()
for (const rule of rules) {
const [key, value] = rule.split(‘:’)
switch (key) {
case ‘string’:
schema = Yup.string()
break
case ‘number’:
schema = Yup.number().typeError(‘Only numbers are allowed’)
break
case ‘checked’:
schema = Yup.boolean().oneOf([true], ‘This checkbox must be selected’)
break
case ‘required’:
schema = schema.required(‘This field is required’)
break
case ’email’:
if (schema instanceof Yup.StringSchema) {
schema = schema.email(‘Invalid email’) }
break
case ‘min’:
if (schema instanceof Yup.StringSchema) {
schema = schema.min(parseInt(value), `Minimum ${value} characters are required`)
} else if (schema instanceof Yup.NumberSchema) {
schema = schema.test(
‘min-digits’,
`Value must have at least ${value} digits`,
(val) => val?.toString().length >= parseInt(value)
)}
break
case ‘max’:
if (schema instanceof Yup.StringSchema) {
schema = schema.max(
parseInt(value),
`Maximum ${value} characters are allowed`
)
} else if (schema instanceof Yup.NumberSchema) {
schema = schema.test(
‘max-digits’,
`Maximum ${value} digits are allowed`,
(val) => val?.toString().length < parseInt(value)
)}
break
default:
throw new Error(`Unsupported validation rule: ${key}`)
}}
return schema
}
const renderField = (field: FormField, formikField: FieldInputProps<any>) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const { showToast } = useToast()
switch (field.type) {
case ‘radio’:
return (
<div className=”flex items-center gap-2″>
<Radio id={field.name} {…formikField} disabled={field?.disabled || false} readOnly={field?.readOnly || false}
/>
<Label htmlFor={field.name} disabled={field?.disabled || false}>
{typeof field.label === ‘string’ ? i18n.t(field.label) : field.label}
</Label>
</div>
)
case ‘checkbox’:
return (
<div className=”flex items-center gap-2″>
<Checkbox id={field.name} {…formikField} checked={formikField?.value || false} disabled={field?.disabled || false}
readOnly={field?.readOnly || false} className=”checkbox”
onChange={(event) => {
formikField.onChange({ target: { name: field.name, value: event.target.checked }})
}}
/>
<Label htmlFor={field.name} disabled={field?.disabled || false}>
{typeof field.label === ‘string’ ? i18n.t(field.label) : field.label}
</Label>
</div>
)
case ‘select’:
return (
<div className=”relative”>
<Select id={field.name} {…formikField} disabled={field?.disabled || false} className=”select-md”
onChange={(e) => {
formikField.onChange({ target: { name: field.name, value: e.target.value }})
field.onOptionChange?.(e)
}}
>
<option key={-1} value=””> Select Option </option>
{field.options?.map((item, index) => (
<option key={index} value={item.key}> {i18n.t(item.value)} </option>
))}
</Select>
<Label htmlFor={field.name} disabled={field?.disabled || false}
className=”absolute left-1 top-2 z-10 origin-[0] -translate-y-4 scale-75 transform bg-white px-2 text-gray-500 duration-300 peer-placeholder-shown:translate-y-2 peer-placeholder-shown:scale-100 peer-focus:top-2 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:text-blue-600 dark:bg-gray-800 dark:text-gray-400″> {typeof field.label === ‘string’ ? i18n.t(field.label) : field.label} </Label>
</div>
)
case ‘textarea’:
return (
<div className=”relative”>
<Textarea id={field.name} {…formikField} disabled={field?.disabled || false} readOnly={field?.readOnly || false}
placeholder=” ”
className=”border-1 peer block w-full appearance-none rounded-md border-gray-300 bg-transparent px-2.5 pb-1.5 pt-3 text-sm text-gray-900 focus:border-blue-600 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-blue-500″
/>
<Label htmlFor={field.name} disabled={field?.disabled || false}
className=”absolute start-1 top-1 z-10 origin-[0] -translate-y-3 scale-75 transform bg-white px-2 text-sm text-gray-500 duration-300 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1 peer-focus:-translate-y-3 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-blue-600 dark:bg-gray-900 dark:text-gray-400 peer-focus:dark:text-blue-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4″> {typeof field.label === ‘string’ ? i18n.t(field.label) : field.label} </Label>
</div>
)
case ‘toggle’:
return (
<ToggleSwitch id={field.name} {…formikField} disabled={field?.disabled || false} checked={formikField?.value || false}
label={typeof field.label === ‘string’ ? i18n.t(field.label) : ”} onChange={(value: boolean) => {
formikField.onChange({ target: { name: field.name, value: value || false }})
}} />)
default:
return (
<FloatingLabel id={field.name} {…formikField} type={field.type} readOnly={field?.readOnly || false} disabled={field?.disabled || false}
variant=”outlined” label={typeof field.label === ‘string’ ? i18n.t(field.label) : ”} sizing=”sm” /> )}}
const DynamicForm: React.FC<DynamicFormProps> = ({ formikRef, fields, onSubmit, submitLabel, isSubmitting, formClassName }) => {
// Helper function to merge nested objects
const setNestedSchema = (
base: Record<string, any>, path: string[], schema: Yup.AnySchema
): Record<string, any> => {
const [current, …rest] = path
if (rest.length === 0) { return { …base, [current]: schema }}
return { …base, [current]: Yup.object({ …(base[current] ? base[current].fields : {}), …setNestedSchema(base[current]?.fields || {}, rest, schema)})}
}
// Main validation schema builder
const validationSchema = Yup.object(
fields.reduce(
(schema, field) => {
if (field?.validationType) {
const fieldPath = field.name.split(‘.’)
const fieldValidation = parseValidation(field.validationType, field.validationMessage)
return setNestedSchema(schema, fieldPath, fieldValidation)
}
return schema
},
{} as Record<string, Yup.AnySchema>
)
)
// Set initial values for nested fields
const setNestedValue = (obj: any, path: string, value: any) => {
const keys = path.split(‘.’)
let current = obj
keys.forEach((key, index) => {
if (!current[key]) { current[key] = index === keys.length – 1 ? value : {}}
current = current[key]
})
}
const initialValues = fields.reduce(
(values, field) => {
const value = field.initialValue ?? ”
setNestedValue(values, field.name, value)
return values
},
{} as Record<string, string | string[] | number | boolean | undefined>
)
return (
<Formik innerRef={formikRef || undefined} initialValues={initialValues} validationSchema={validationSchema}
onSubmit={async (values, { resetForm }) => {
if (onSubmit) onSubmit(values)
}}
>
{({ errors, touched }) => (
<Form className={formClassName ? `mt-3 ${formClassName}` : `mt-3`}>
{fields.map(
(field) =>
!field.hidden && (
<div key={field.name} className={ field.className ? `mb-2 ${field.className}` : `mb-2`}>
<Field name={field.name}> {({ field: formikField }: { field: FieldInputProps<any> }) => renderField(field, formikField)} </Field>
{getIn(touched, field.name) && getIn(errors, field.name) && (
<span className=”text-xs text-red-500″> {getIn(errors, field.name)} </span>
)}
</div>
)
)}
{submitLabel && (
<Button type=”submit” color={‘primary’} className=”w-full rounded-md” isProcessing={isSubmitting ? true : false} disabled={isSubmitting ? true : false}> {submitLabel} </Button>
)}
</Form>
)}
</Formik>
)
}
export default DynamicForm
Step 2: How to use above component into another component
Below is the Info Component implementation styled with Tailwind CSS.
import React from ‘react’
import DynamicForm from ‘@/app/components/shared/DynamicForm’
import { User } from ‘@/app/types/User’
import { KeyValue } from ‘@/app/types/GlobalType’
import { FormikProps } from ‘formik’
import { useAuthorization } from ‘@/app/context/AuthorizationContext’
import { USER_STATUSES } from ‘@/app/(DashboardLayout)/models/users.model’
import { DialogParamsWithAuth, UsersAuthorizations } from ‘@/app/types/Authorization’
const Info = React.memo(
({ formikRef, user, userImage, params, locales, userRoles, onRoleChange }: {
formikRef: (ref: FormikProps<any>) => void
user: User | null
userImage: string | null
params?: DialogParamsWithAuth<User, UsersAuthorizations>
locales: KeyValue[]
userRoles: KeyValue[]
onRoleChange: (event: React.ChangeEvent<HTMLSelectElement>) => void
}) => {
const { isAdmin, isSuperAdmin } = useAuthorization()
const fields = [
{
name: ‘image’, type: ‘file’,
initialValue: userImage || ”, className: ‘sm:col-span-4 col-span-12’,
},
{
name: ‘firstName’, type: ‘text’, label: ‘First Name’,
validationType: ‘string|required|max:255’,
initialValue: user?.firstName || ”, className: ‘sm:col-span-6 col-span-12′,
},
{
name: ’email’, type: ‘text’, label: ‘Email’,
validationType: ‘string|email|required’,
initialValue: user?.email || ”, className: ‘sm:col-span-6 col-span-12’,
},
{
name: ‘role’, type: ‘select’, label: ‘Role’,
validationType: ‘string|required|pattern:^[A-Z0-9- ]*$’,
initialValue: user?.role || ”, className: ‘sm:col-span-6 col-span-12’,
options: userRoles,
onOptionChange: onRoleChange,
hidden: !isAdmin && !isSuperAdmin(),
},
{
name: ‘mobile’, type: ‘text’, label: ‘Mobile’,
validationType: ‘string|pattern:^\\+?([0-9] ?){9,14}[0-9]$’,
validationMessage: ‘users.invalid_phone_number’,
initialValue: user?.mobile || ”, className: ‘sm:col-span-6 col-span-12’,
},
{
name: ‘plateID’, type: ‘text’, label: ‘Plate’,
validationType: ‘string|pattern:^[A-Z0-9- ]*$’,
validationMessage: ‘users.invalid_plate_id’,
initialValue: user?.plateID || ”, className: ‘sm:col-span-6 col-span-12’,
},
{
name: ‘freeAccess’, type: ‘checkbox’, label: ‘Free access to all charging stations’,
initialValue: user?.freeAccess || false, className: ‘col-span-12’,
hidden: !user?.projectFields?.includes(‘freeAccess’) || !billingComponentActive,
},
]
return (
<DynamicForm formikRef={formikRef} fields={fields} formClassName=”grid grid-cols-12 gap-2″ />
)})
export default Info
Conclusion :
Dynamic forms in Next.js, styled with Tailwind CSS, provide a powerful way to manage flexible input fields. By combining Formik’s state management with Yup’s validation and Tailwind’s utility-first styling, you can build highly interactive and scalable forms for your applications.
Try implementing this in your project and watch your forms adapt to your users’ needs effortlessly.