2025-06-01 Web Development
Building a Dynamic Form System with Configuration-Driven Design in Next.js
By O. Wolfson
Modern web applications often require a flexible system to manage forms—capable of adapting to changing data structures and supporting reusability across different entity types.
Source code
Demo
✨ Why Config-Driven Forms?
By defining form structure in configuration files, we decouple form rendering logic from form content, enabling:
- Easy addition of new forms without changing the core rendering code.
- Separation of concerns (structure vs logic).
- Custom component support per field (e.g. special editors, selectors).
- Reusability across domains like inventory, transactions, users, etc.
🧪 How It Works
A typical setup includes:
1. FieldConfig
Types
Each field in the form is described using a common config schema like:
tstype FieldType =
| "text"
| "textarea"
| "checkbox"
| "select"
| "date"
| "custom"
| "json-editor";
type FieldConfig = {
name: string;
label: string;
type: FieldType;
placeholder?: string;
Component?: (props: { formValues: any }) => JSX.Element; // for custom rendering
};
2. Entity Config Files
Each formable entity defines a config:
tsexport const entityConfig = {
schema: z.object({
title: z.string().min(1),
notes: z.string().optional(),
}),
fields: [
{ name: "title", label: "Title", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
],
};
3. DynamicForm Component
The core renderer reads the config and uses react-hook-form
to manage state and validation.
🧪 Tutorial: Create a Dynamic Form System in Next.js (App Router)
We'll build a minimal form builder from scratch.
✅ Step 1: Project Setup
bashnpx create-next-app@latest dynamic-forms --app --typescript
cd dynamic-forms
npm install zod react-hook-form @hookform/resolvers
✅ Step 2: Define Field Types
Create src/form/types.ts
tsexport type FieldType = "text" | "textarea" | "json-editor";
export type FieldConfig = {
name: string;
label: string;
type: FieldType;
};
export type EntityConfig = {
schema: any;
fields: FieldConfig[];
};
✅ Step 3: Dynamic Form Renderer
Create src/components/DynamicForm.tsx
tsx"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { EntityConfig } from "../form/types";
export default function DynamicForm({
config,
onSubmit,
}: {
config: EntityConfig;
onSubmit: (values: any) => void;
}) {
const form = useForm({
resolver: zodResolver(config.schema),
});
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{config.fields.map((f) => (
<div key={f.name}>
<label>{f.label}</label>
{f.type === "text" && <input {...form.register(f.name)} />}
{f.type === "textarea" && <textarea {...form.register(f.name)} />}
</div>
))}
<button type="submit">Submit</button>
</form>
);
}
✅ Step 4: Create Sample Entities
ts// src/entities/note.ts
import { z } from "zod";
export const noteConfig = {
schema: z.object({
title: z.string(),
content: z.string().optional(),
}),
fields: [
{ name: "title", label: "Title", type: "text" },
{ name: "content", label: "Content", type: "textarea" },
],
};
ts// src/entities/task.ts
import { z } from "zod";
export const taskConfig = {
schema: z.object({
task: z.string(),
detail: z.string().optional(),
}),
fields: [
{ name: "task", label: "Task", type: "text" },
{ name: "detail", label: "Detail", type: "textarea" },
],
};
✅ Step 5: Use in a Route
tsx// app/page.tsx
import DynamicForm from "@/components/DynamicForm";
import { noteConfig } from "@/entities/note";
export default function Page() {
return (
<main className="p-6">
<h1>Note Form</h1>
<DynamicForm config={noteConfig} onSubmit={console.log} />
</main>
);
}
✅ Benefits
- Clean separation between structure and render logic.
- Easily extendable.
- Powerful for admin panels, schema-driven UIs, CMS tools.