June 1, 2025
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.
By defining form structure in configuration files, we decouple form rendering logic from form content, enabling:
A typical setup includes:
FieldConfig TypesEach 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
};
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" },
],
};
The core renderer reads the config and uses react-hook-form to manage state and validation.
We'll build a minimal form builder from scratch.
bashnpx create-next-app@latest dynamic-forms --app --typescript
cd dynamic-forms
npm install zod react-hook-form @hookform/resolvers
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[];
};
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>
);
}
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" },
],
};
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>
);
}