text-to-form-generator
v0.0.7
Published
Text to Form Generator is a CLI tool that converts simple text field definitions into fully wired forms with Zod validation, server actions, and a React client component
Maintainers
Keywords
Readme
Text to Form Generator
Text to Form Generator is a CLI tool that converts simple text field definitions into fully wired forms with Zod validation, server actions, and a React client component.
It is ideal for quickly scaffolding forms and server flows for testing or prototyping without writing repetitive boilerplate.
You can instruct your agent to use this tool to generate forms for you.
Features
- Generate a ready-to-use Next.js server action from a single CLI command.
- Automatically create a Zod schema for all fields, including basic per-field validation messages.
- Produce a React client component wired to the server action using
useActionState. - All fields are mandatory by default, with a flag to make everything optional.
- Supports multiple field types such as
string,number,textarea, andcheckbox.
How it works
You describe your fields as a simple comma-separated list of name:type pairs, and the CLI generates:
- A Zod schema object.
- A typed server action that validates, calls your API, and returns a
FormState. - A React client component with a fully styled form and redirect behavior.
All fields are validated with Zod, and errors are mapped back into a consistent FormState shape for the client.
Quick start
Run the generator directly with pnpx (no global install required):
pnpx text-to-form-generator \
--inputType separate \
--fields "name:string, age:number, bio:textarea, isAdmin:checkbox" \
--redirectTo "/some/path" \
--endpoint "/api/some/endpoint"This command will generate:
- A Zod schema named
mySchema. - A
submitFormActionserver function. - A
PageReact component that renders the form and handles redirects and toasts. - A redirect to
/some/pathon successful submission. - A
POSTrequest to/api/some/endpointwith the form data.
Output
Server action
"use server";
import { z } from "zod";
import {
validateWithZod,
createErrorResponse,
type FormState,
} from "@/lib/form-utils";
const mySchema = z.object({
name: z.string().min(1, "Please enter a valid name"),
age: z.number().min(1, "Please enter a valid age"),
bio: z.string().min(1, "Please enter a valid bio"),
isAdmin: z.boolean(),
});
type MyFormData = z.infer<typeof mySchema>;
export type MyFormState = FormState<MyFormData>;
export async function submitFormAction(
_prevState: MyFormState,
formData: FormData
): Promise<MyFormState> {
const rawData = {
name: formData.get("name") as string,
age: Number(formData.get("age")),
bio: formData.get("bio") as string,
isAdmin: formData.get("isAdmin") === "on",
};
const result = validateWithZod(mySchema, rawData);
if (result.success === false) {
return result.formState;
}
try {
const response = await fetch(
`${
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000"
}/api/users`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...result.data }),
}
);
const data = await response.json();
if (!response.ok) {
return createErrorResponse<MyFormData>(
{ ...result.data },
"Please try again",
data
);
}
return { data: { ...result.data }, redirectTo: "/users" };
} catch (error) {
return createErrorResponse<MyFormData>(
rawData,
"An unexpected error occurred. Please try again later."
);
}
}Frontend
"use client";
import { cn } from "@/lib/utils";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getFirstError } from "@/lib/form-utils";
import { toast } from "sonner";
import { MyFormState, submitFormAction } from "./actions";
const initial_state: MyFormState = {
data: {
name: "",
age: 0,
bio: "",
isAdmin: false,
},
};
export default function Page() {
const router = useRouter();
const [formState, submitAction, isPending] = useActionState(
submitFormAction,
initial_state
);
useEffect(() => {
if (formState?.redirectTo) {
router.push(formState.redirectTo);
}
}, [formState?.redirectTo, router]);
useEffect(() => {
const error = getFirstError(formState?.errors);
if (error) {
toast.error(error);
}
}, [formState?.errors]);
return (
<div className="card bg-base-100 max-w-2xl mx-auto py-8 w-full">
<div className="card-body">
<div className="max-w-md mx-auto w-full">
<h2 className="text-3xl font-bold top-0 sticky overflow-y-auto py-4 bg-base-100 z-10">
MYSCHEMA
</h2>
<form action={submitAction}>
<fieldset className="fieldset flex flex-col mb-4">
<label className="label">Name</label>
<input
name="name"
className={cn(
"input w-full",
formState?.errors?.name && "input-error"
)}
type="text"
placeholder="Name"
defaultValue={
formState?.data?.name || initial_state.data?.name || ""
}
/>
<label className="label">Age</label>
<input
name="age"
className={cn(
"input w-full",
formState?.errors?.age && "input-error"
)}
type="number"
placeholder="Age"
defaultValue={
formState?.data?.age || initial_state.data?.age || ""
}
/>
<label className="label">Bio</label>
<textarea
name="bio"
className={cn(
"textarea w-full",
formState?.errors?.bio && "textarea-error"
)}
placeholder="Bio"
defaultValue={
formState?.data?.bio || initial_state.data?.bio || ""
}
/>
<label className="label">
<input
type="checkbox"
name="isAdmin"
className={cn(
"checkbox",
formState?.errors?.isAdmin && "checkbox-error"
)}
defaultChecked={
formState?.data?.isAdmin ||
initial_state.data?.isAdmin ||
false
}
/>
IsAdmin
</label>
</fieldset>
<div className="max-w-md py-4 fixed bottom-0 left-0 right-0 p-4 mx-auto bg-base-100 z-10">
<button
type="submit"
className="btn btn-primary w-full"
disabled={isPending}
>
{isPending ? "Continue..." : "Continue"}
</button>
</div>
</form>
</div>
</div>
</div>
);
}CLI usage
Basic command
pnpx text-to-form-generator --inputType separate --fields "name:string, age:number, bio:textarea, isAdmin:checkbox" --redirectTo "/users"
Flags:
--inputType separate– treat--fieldsas a comma-separated list ofname:typedefinitions.--fields– required, defines your form fields and their types.--redirectTo– optional, sets the redirect URL used on successful submission.
Generate the action without redirect
If you want to handle redirects manually, disable redirect output from the action:
pnpx text-to-form-generator --inputType separate --fields "name:string, age:number, bio:textarea, isAdmin:checkbox" --noRedirect
Using --noRedirect removes the redirectTo value from the generated action’s return type and logic.
Make all fields optional
To turn every field into an optional Zod field, use the --allOptional flag:
pnpx text-to-form-generator --inputType separate --fields "name:string, age:number, bio:textarea, isAdmin:checkbox" --allOptional
This will wrap each generated Zod field with .optional(), so no value is strictly required.
Dependencies
You need to provide your own form-utils.ts implementation file.
Here is a simple approach:
import { z } from "zod";
export type FormState<T extends Record<string, any>> = {
data?: Partial<T>;
errors?: Partial<Record<keyof T | "general", string[]>>;
redirectTo?: string;
success?: boolean;
message?: string;
};
type ValidationResult<T> =
| { success: true; data: T }
| { success: false; formState: FormState<T> };
export function validateWithZod<T extends Record<string, any>>(
schema: z.ZodSchema<T>,
rawData: Partial<T>
): ValidationResult<T> {
const validation = schema.safeParse(rawData);
if (!validation.success) {
const errors: Partial<Record<keyof T | "general", string[]>> = {};
validation.error.issues.forEach((issue) => {
const field = issue.path[0] as keyof T;
if (!errors[field]) {
errors[field] = [];
}
errors[field]!.push(issue.message);
});
return {
success: false,
formState: { data: rawData, errors },
};
}
return { success: true, data: validation.data };
}
export function createErrorResponse<T extends Record<string, any>>(
data: Partial<T>,
generalMessage: string,
apiError?: { errors?: Array<{ message: string; path?: string[] }> }
): FormState<T> {
const errors: Partial<Record<keyof T | "general", string[]>> = {};
errors.general = [generalMessage];
if (apiError?.errors) {
apiError.errors.forEach((error) => {
if (error.path && error.path.length > 0) {
const field = error.path[0] as keyof T;
if (field in data && !errors[field]) {
errors[field] = [error.message];
}
}
});
}
return { data, errors };
}
// Get first error message - field errors have priority, then general
export function getFirstError<T extends Record<string, any>>(
errors?: FormState<T>["errors"],
field?: keyof T | "general"
): string | undefined {
if (!errors) return undefined;
// If specific field requested, return its first error
if (field !== undefined) {
const fieldErrors = errors[field];
return fieldErrors && fieldErrors.length > 0 ? fieldErrors[0] : undefined;
}
// No field specified - check field errors first (priority)
for (const key in errors) {
if (key !== "general" && errors[key] && errors[key]!.length > 0) {
return errors[key]![0];
}
}
// Then check general errors (fallback)
if (errors.general && errors.general.length > 0) {
return errors.general[0];
}
return undefined;
}
// Get all errors for a specific field
export function getFieldErrors<T extends Record<string, any>>(
errors?: FormState<T>["errors"],
field?: keyof T | "general"
): string[] {
if (!errors || !field) return [];
return errors[field] || [];
}
// Get all error messages as a flat array
export function getAllErrors<T extends Record<string, any>>(
errors?: FormState<T>["errors"]
): string[] {
if (!errors) return [];
const allErrors: string[] = [];
// Add general errors
if (errors.general) {
allErrors.push(...errors.general);
}
// Add field errors
for (const key in errors) {
if (key !== "general" && errors[key]) {
allErrors.push(...errors[key]!);
}
}
return allErrors;
}And a form-hook.ts file:
"use client";
import { useEffect, RefObject } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FormState, getFirstError } from "./form-utils";
// A hook to manage the side effects of a form submission
type UseFormEffectsOptions<T> = {
onSuccess?: (data: Partial<T>) => void;
redirect?: boolean;
toastErrors?: boolean;
};
export function useFormEffects<T extends Record<string, any>>(
formState: FormState<T> | undefined,
formRef?: RefObject<HTMLFormElement | null>,
options: UseFormEffectsOptions<T> = {}
) {
const router = useRouter();
const { onSuccess, redirect = true, toastErrors = true } = options;
// Handle redirects
useEffect(() => {
if (redirect && formState?.redirectTo) {
router.push(formState.redirectTo);
}
}, [formState?.redirectTo, router, redirect]);
// Handle errors
useEffect(() => {
if (toastErrors) {
const error = getFirstError(formState?.errors);
if (error) {
toast.error(error);
}
}
}, [formState?.errors, toastErrors]);
// Handle success and data updates
useEffect(() => {
if (formState?.success && formState.data) {
// If a formRef is provided, try to auto-populate fields that match returned data keys
if (formRef?.current) {
Object.entries(formState.data).forEach(([key, value]) => {
// Skip undefined values
if (value === undefined || value === null) return;
const input = formRef.current?.elements.namedItem(key) as
| HTMLInputElement
| RadioNodeList;
if (input) {
// Handle RadioNodeList (radios)
if (input instanceof RadioNodeList) {
input.value = String(value);
}
// Handle standard inputs (text, hidden, etc) - excluding file inputs which are read-only
else if (
input instanceof HTMLInputElement &&
input.type !== "file"
) {
input.value = String(value);
}
// Handle select/textarea if needed (though namedItem returns Element | RadioNodeList)
else if ("value" in input) {
(input as any).value = String(value);
}
}
});
}
// Run custom success callback
if (onSuccess) {
onSuccess(formState.data);
}
}
}, [formState, formRef, onSuccess]);
}Example of a form produced and styled with Tailwind CSS:

