npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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, and checkbox.

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 submitFormAction server function.
  • A Page React component that renders the form and handles redirects and toasts.
  • A redirect to /some/path on successful submission.
  • A POST request to /api/some/endpoint with 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 --fields as a comma-separated list of name:type definitions.
  • --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:

alt text