JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 200
  • Score
    100M100P100Q73443F
  • License MIT

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

Package Exports

  • @classytic/formkit
  • @classytic/formkit/package.json
  • @classytic/formkit/server

Readme

@classytic/formkit

Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.

npm License: MIT TypeScript

Features

  • Headless - Bring your own UI components (Shadcn, MUI, Chakra, etc.) via a one-time component/layout adapter. Already using shadcn? Skip the adapter entirely with the prebuilt one in @classytic/fluid/formkit — its SchemaForm is genuinely a schema + onSubmit away.
  • Minimal boilerplate - once an adapter is registered, useFormKit wires a full form (defaults + RHF + generator props) in a few lines
  • Schema-driven - Define forms with JSON/TypeScript schemas, defaults extracted automatically
  • Type-safe - Full TypeScript support with generics
  • React Hook Form - Built on top of the best form library, referentially stable return values
  • React 19 - Uses modern React 19 patterns (Context as provider, ref as prop)
  • Server Components - Dedicated @classytic/formkit/server entry point for RSC
  • Variants - Support for multiple component variants
  • Conditional fields - Show/hide fields based on form values (function, DSL rules, AND/OR logic)
  • Responsive layouts - Multi-column grid layouts
  • Accessibility - Auto-generated fieldId, error, and fieldState props
  • Validation helpers - buildValidationRules generates RHF rules from schema props
  • Lightweight - ~17KB gzipped (peer deps excluded, everything included), tree-shakeable

Requirements

  • React 19.0+ (React 18 is not supported)
  • React Hook Form 7.55.0+

Installation

npm install @classytic/formkit react-hook-form
# or
pnpm add @classytic/formkit react-hook-form
# or
yarn add @classytic/formkit react-hook-form

Quick Start

1. Create Field Components

Each field component receives FieldComponentProps including error, fieldId, and the full field config:

// components/form/form-input.tsx
"use client";

import { Controller } from "react-hook-form";
import type { FieldComponentProps } from "@classytic/formkit";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function FormInput({
  control,
  name,
  rules,       // pre-computed RHF rules from schema (required, minLength, pattern, validate…)
  label,
  placeholder,
  required,
  fieldId,     // use as id on <input> and htmlFor on <Label>
  errorId,     // use as id on error <p> and aria-errormessage on <input>
  shouldShowError, // true only after touch/submit — mirrors :user-invalid timing
  error,
}: FieldComponentProps) {
  return (
    <Controller
      name={name}
      control={control}
      rules={rules}
      render={({ field }) => (
        <div className="space-y-2">
          {label && (
            <Label htmlFor={fieldId}>
              {label}
              {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
            </Label>
          )}
          <Input
            {...field}
            id={fieldId}
            placeholder={placeholder}
            aria-required={required || undefined}
            aria-invalid={shouldShowError || undefined}
            aria-errormessage={shouldShowError ? errorId : undefined}
          />
          {shouldShowError && (
            <p id={errorId} role="alert" className="text-sm text-red-500">
              {error?.message}
            </p>
          )}
        </div>
      )}
    />
  );
}

2. Create Form Adapter

Register your components and layouts:

// lib/form-adapter.tsx
"use client";

import {
  FormSystemProvider,
  type ComponentRegistry,
  type LayoutRegistry,
} from "@classytic/formkit";
import { FormInput } from "@/components/form/form-input";

const components: ComponentRegistry = {
  text: FormInput,
  email: FormInput,
  password: FormInput,
  // Add more field types...
};

const layouts: LayoutRegistry = {
  section: ({ title, description, children }) => (
    <div className="space-y-4">
      {title && <h3 className="text-lg font-semibold">{title}</h3>}
      {description && <p className="text-muted-foreground">{description}</p>}
      {children}
    </div>
  ),
  grid: ({ children, cols = 1 }) => {
    // Map to STATIC class names. A template like `grid-cols-${cols}` is never
    // generated by Tailwind's JIT (it only sees whole class strings in source).
    const COLS: Record<number, string> = {
      1: "grid-cols-1",
      2: "grid-cols-1 md:grid-cols-2",
      3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
    };
    return <div className={`grid gap-4 ${COLS[cols] ?? "grid-cols-1"}`}>{children}</div>;
  },
};

export function FormProvider({ children }: { children: React.ReactNode }) {
  return (
    <FormSystemProvider components={components} layouts={layouts}>
      {children}
    </FormSystemProvider>
  );
}

3. Use FormGenerator

// app/signup/page.tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormGenerator, useFormKit, type FormSchema } from "@classytic/formkit";
import { FormProvider } from "@/lib/form-adapter";

const signupSchema = z.object({
  firstName: z.string().min(2),
  lastName: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

type SignupData = z.infer<typeof signupSchema>;

const formSchema: FormSchema<SignupData> = {
  sections: [
    {
      title: "Personal Information",
      cols: 2,
      fields: [
        { name: "firstName", type: "text", label: "First Name", required: true, defaultValue: "" },
        { name: "lastName", type: "text", label: "Last Name", required: true, defaultValue: "" },
      ],
    },
    {
      title: "Account",
      fields: [
        { name: "email", type: "email", label: "Email", required: true, defaultValue: "" },
        { name: "password", type: "password", label: "Password", required: true, defaultValue: "" },
      ],
    },
  ],
};

export default function SignupPage() {
  const { handleSubmit, generatorProps } = useFormKit({
    schema: formSchema,
    resolver: zodResolver(signupSchema),
  });

  return (
    <FormProvider>
      <form onSubmit={handleSubmit(console.log)} className="space-y-8">
        <FormGenerator {...generatorProps} />
        <button type="submit">Sign Up</button>
      </form>
    </FormProvider>
  );
}

Type safety

Field names are enforced against your form type at authoring time by the builders. Use field.for<T>(), defineField<T>(), or section<T>() and a typo is a compile error:

const f = field.for<SignupData>();
f.text("emial", "Email"); // ✗ compile error — "emial" is not a key of SignupData
f.text("email", "Email"); // ✓

The <FormGenerator schema> / useFormKit({ schema }) props themselves accept FormSchema<any> by design: a schema's condition functions ((values) => …) make FormSchema<T> contravariant in T, so pinning the prop to the form's T would force a cast at every call site. Author with the typed builders for name safety; the generator stays permissive so any built schema drops in without ceremony. itemFields (group/array children) use relative names and are resolved at render time, so they are typed loosely on purpose.

API Reference

useFormKit

Convenience hook that combines schema default extraction with react-hook-form setup. Returns all useForm methods plus ready-to-spread generatorProps.

Referentially stable — the return value preserves the original useForm object identity across re-renders, so it's safe to use in useEffect dependency arrays.

import { useFormKit, FormGenerator } from "@classytic/formkit";

const form = useFormKit({
  schema: formSchema,
  resolver: zodResolver(validationSchema), // optional
  defaultValues: { email: "pre@fill.com" }, // optional overrides
  disabled: false,                          // optional
  variant: "compact",                       // optional
  className: "my-form",                     // optional
  resetOnSchemaChange: true,                // optional — re-seed on schema swap
  mode: "onBlur",                           // any useForm option
});

const { handleSubmit, generatorProps } = form;

// Safe to use in useEffect deps — form is referentially stable
useEffect(() => {
  if (open) form.reset(defaults);
}, [open, form]);

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <FormGenerator {...generatorProps} />
    <button type="submit">Submit</button>
  </form>
);

Schema defaultValue fields are automatically extracted and merged with any explicit defaultValues you provide (explicit values take priority). Nested/namespaced defaults are extracted as a nested object ({ address: { city } }), so react-hook-form's dot-path resolution applies them correctly.

Pass resetOnSchemaChange: true to re-seed the form when the schema identity changes at runtime (e.g. a schema loaded from a DB is swapped in). It's off by default because a reset discards in-progress edits.

generatorProps is memoized — it only recomputes when schema, control, disabled, variant, or className change.

FormGenerator

The main component that renders forms from a schema. Supports React 19 ref as a regular prop.

<FormGenerator
  schema={formSchema}       // Required: Form schema
  control={form.control}    // Optional: React Hook Form control (or wrap in <FormProvider>)
  disabled={false}           // Optional: Disable all fields
  variant="default"          // Optional: Global variant
  className="my-form"        // Optional: Root element class
  ref={formRef}              // Optional: Ref to the root <div> (React 19 ref-as-prop)
/>

FormSchema

interface FormSchema<T extends FieldValues = FieldValues> {
  sections: Section<T>[];
}

Section

interface Section<T> {
  id?: string;                  // Unique identifier
  title?: string;               // Section title
  description?: string;         // Section description
  icon?: ReactNode;             // Section icon
  fields?: BaseField<T>[];      // Fields in this section
  cols?: number;                // Grid columns (1-6)
  gap?: number;                 // Grid gap
  variant?: string;             // Section variant
  className?: string;           // Custom class
  collapsible?: boolean;        // Make section collapsible
  defaultCollapsed?: boolean;
  nameSpace?: string;           // Prefix for nested object fields (e.g. "address")

  // Conditional rendering (function, DSL rule, or ConditionConfig)
  condition?: Condition<T>;

  // Custom render function (bypasses grid layout)
  render?: (props: SectionRenderProps<T>) => ReactNode;
}

BaseField

interface BaseField<T> {
  // name accepts Path<T> or any string.
  // Use field.for<T>() builders for call-site enforcement of valid paths.
  // Relative names are correct for nameSpace sections and itemFields children —
  // FormGenerator prefixes them at render time (e.g. "street" → "address.street").
  name: Path<T> | string;       // Field name (required)
  type: FieldType;              // Field type (required)
  label?: string;               // Field label
  placeholder?: string;         // Placeholder text
  helperText?: string;          // Helper text below field
  disabled?: boolean;           // Disable field
  required?: boolean;           // Mark as required
  readOnly?: boolean;           // Read-only field
  variant?: string;             // Field variant
  fullWidth?: boolean;          // Span full grid width
  className?: string;           // Custom class
  defaultValue?: unknown;       // Default value

  // Conditional rendering
  condition?: Condition<T>;
  watchNames?: string | string[];  // Optimize useWatch performance

  // Dynamic options loading
  loadOptions?: (formValues: Partial<T>) => Promise<FieldOption[]> | FieldOption[];
  debounceMs?: number;

  // Sub-fields for group and array types.
  // Children use relative names ("street") — FormGenerator prefixes with parent name.
  // Intentionally untyped to T because they resolve at render time, not authoring time.
  itemFields?: BaseField[];

  // For select/radio/checkbox
  options?: FieldOption[];

  // HTML input attributes
  min?: number | string;
  max?: number | string;
  step?: number;
  pattern?: string;
  minLength?: number;
  maxLength?: number;
  rows?: number;
  multiple?: boolean;
  accept?: string;
  autoComplete?: string;
  autoFocus?: boolean;

  // Custom render override
  render?: (props: FieldComponentProps<T>) => ReactNode;

  // Arbitrary extra props for custom components
  customProps?: Record<string, unknown>;
}

FieldComponentProps

Props passed to your field components:

interface FieldComponentProps<T extends FieldValues = FieldValues>
  extends BaseField<T> {
  field: BaseField<T>;          // Full field config
  control: Control<T>;          // React Hook Form control
  disabled?: boolean;           // Merged disabled state
  variant?: string;             // Active variant
  error?: FieldError;           // Field error from react-hook-form
  fieldState?: {                // Field state metadata
    invalid: boolean;
    isDirty: boolean;
    isTouched: boolean;
    isValidating: boolean;
    isSubmitted: boolean;
    error?: FieldError;
  };
  fieldId: string;              // Generated ID for label-input association (e.g. "formkit-field-email")
  errorId: string;              // ID for the error node — set as aria-errormessage on the input
  shouldShowError: boolean;     // true only after touch/submit (mirrors :user-invalid timing)
  rules: RegisterOptions;       // Pre-computed RHF rules from schema (required/min/max/pattern…)
  isLoading?: boolean;          // true while async loadOptions is in flight
}

Condition Types

Conditions can be a function, a DSL rule, an array of rules (AND), or a ConditionConfig (AND/OR):

// Function condition
condition: (values) => values.accountType === "business"

// Single DSL rule
condition: { watch: "country", operator: "===", value: "US" }

// Array of rules (AND - all must match)
condition: [
  { watch: "country", operator: "===", value: "US" },
  { watch: "age", operator: "truthy" },
]

// ConditionConfig with OR logic
condition: {
  rules: [
    { watch: "country", operator: "===", value: "US" },
    { watch: "country", operator: "===", value: "CA" },
  ],
  logic: "or",
}

Supported operators: ===, !==, in, not-in, truthy, falsy

Nested paths: DSL rules support dot-notation paths like "address.city" for nested form values.

ComponentRegistry

const components: ComponentRegistry = {
  // Simple mapping
  text: FormInput,
  select: FormSelect,

  // Variant-specific components
  compact: {
    text: CompactInput,
    select: CompactSelect,
  },
};

LayoutRegistry

const layouts: LayoutRegistry = {
  section: SectionLayout,
  grid: GridLayout,

  // Variant-specific layouts
  compact: {
    section: CompactSection,
  },
};

extractDefaultValues

Extracts default values from a schema. Server-safe (no hooks).

import { extractDefaultValues } from "@classytic/formkit"; // or /server

const defaults = extractDefaultValues(formSchema);
// { firstName: "", lastName: "", email: "", password: "" }

// Use with react-hook-form
const form = useForm({ defaultValues: defaults });

Respects nameSpace prefixes and group itemFields defaults.

buildValidationRules

Generates react-hook-form validation rules from a field's schema props. Server-safe (no hooks).

import { buildValidationRules } from "@classytic/formkit"; // or /server

function FormInput({ field, control, error, fieldId }: FieldComponentProps) {
  const rules = buildValidationRules(field);
  return (
    <Controller
      name={field.name}
      control={control}
      rules={rules}
      render={({ field: rhf }) => <input {...rhf} id={fieldId} />}
    />
  );
}

Maps required, min, max, minLength, maxLength, and pattern from the field schema to RHF-compatible rules with auto-generated error messages.

Validation & helpers

validateSchema

Structurally validate a schema (great for DB-loaded / resetOnSchemaChange schemas). Server-safe; returns SchemaIssue[] (empty ⇒ OK). FormGenerator also runs it automatically in dev and console.warns any issues.

import { validateSchema } from "@classytic/formkit"; // or /server

for (const issue of validateSchema(schema)) {
  console.warn(`${issue.severity} ${issue.path}: ${issue.message}`);
}
// Detects: missing name/type, duplicate names (namespace-aware),
// itemFields on a non-container, empty containers, unknown DSL operators.

It validates shape, not your component registry — an unregistered field type is a rendering concern, surfaced separately (see onMissingComponent).

focusFirstError

Focus + scroll to the first visibly-invalid field, in layout order. Wire it into the submit's invalid handler:

import { focusFirstError } from "@classytic/formkit";

<form onSubmit={handleSubmit(onValid, () => focusFirstError())}>

React Hook Form calls the invalid handler before React commits the re-render that marks fields invalid, so if the immediate query finds nothing, focusFirstError automatically retries on the next two animation frames — the plain wiring above works on the very first submit. It returns true when a field was focused immediately, false when the attempt was deferred.

onMissingComponent

If a field's type has no registered component, the form renders a visible placeholder (dev and prod — never a silent drop or a wrong-widget text input) and notifies this callback so you can log the gap. hidden fields are the one exception: with no registered component they render nothing by design (the value still lives in form state); register a hidden component to override.

<FormSystemProvider
  components={components}
  layouts={layouts}
  onMissingComponent={(type) => reportToTelemetry("formkit_missing_component", { type })}
>

Security note: buildValidationRules compiles a field's pattern into a new RegExp(...). If your schemas are ever authored by untrusted users, treat pattern as a ReDoS surface — validate/limit it before persisting.

Server Components

The @classytic/formkit/server entry point exports server-safe utilities with no React hooks or client-side code:

import {
  cn,
  defineSchema,
  defineField,
  defineSection,
  evaluateCondition,
  extractWatchNames,
  extractDefaultValues,
  buildValidationRules,
} from "@classytic/formkit/server";

// Type-only imports also available
import type {
  FormSchema,
  BaseField,
  Section,
  ConditionRule,
  ConditionConfig,
} from "@classytic/formkit/server";

Use this entry point in React Server Components to define schemas, evaluate conditions, or use cn without pulling in client-side code.

Passing a schema across the RSC → Client boundary

FormGenerator is a Client Component (it drives react-hook-form). If you build a schema in a Server Component and pass it as a prop into the client, React requires that prop to be serializable — so function members won't cross the boundary:

  • ❌ function condition: (values) => …, custom render, and loadOptions (functions aren't serializable)
  • DSL conditions ({ watch, operator, value }), and all the plain data fields (name, type, label, options, required, min/max, …)

For fully static, server-defined schemas use DSL conditions. If a schema needs function conditions / custom renders / async options, define it inside the Client Component (e.g. the file that renders FormGenerator) rather than passing it down from an RSC. This is a general Next.js constraint, not specific to formkit.

Advanced Features

Conditional Fields (Function)

{
  name: "companyName",
  type: "text",
  label: "Company Name",
  condition: (values) => values.accountType === "business",
}

Performance: a function condition can't be statically analyzed, so the field subscribes to the whole form and re-evaluates on every change. For large forms prefer a DSL rule (below, whose watched paths are auto-scoped), or add watchNames to scope the subscription yourself: { condition: (v) => v.accountType === "business", watchNames: ["accountType"] }.

Conditional Fields (DSL Rules)

{
  name: "stateField",
  type: "select",
  label: "State",
  condition: { watch: "country", operator: "===", value: "US" },
  watchNames: ["country"], // Optimizes re-renders
}

Conditional Sections

{
  title: "Business Details",
  condition: (values) => values.accountType === "business",
  fields: [
    { name: "companyName", type: "text", label: "Company" },
    { name: "taxId", type: "text", label: "Tax ID" },
  ],
}

OR Conditions

{
  name: "taxField",
  type: "text",
  condition: {
    rules: [
      { watch: "country", operator: "===", value: "US" },
      { watch: "country", operator: "===", value: "CA" },
    ],
    logic: "or",
  },
}

Nested Path Conditions

DSL rules resolve dot-notation paths for nested form values:

{
  name: "zipCode",
  type: "text",
  condition: { watch: "address.country", operator: "===", value: "US" },
}

Namespace Support

Prefix all field names in a section with a namespace for nested objects:

{
  nameSpace: "address",
  fields: [
    { name: "street", type: "text" },  // Becomes "address.street"
    { name: "city", type: "text" },    // Becomes "address.city"
  ],
}

Variants

Apply different styles based on context:

// Register variant-specific components
const components = {
  text: DefaultInput,
  compact: {
    text: CompactInput,
  },
};

// Use variant on the whole form
<FormGenerator schema={schema} variant="compact" />

// Or per-section
{ variant: "compact", fields: [...] }

// Or per-field
{ name: "notes", type: "text", variant: "compact" }

Dynamic Options Loading

{
  name: "city",
  type: "select",
  watchNames: ["country"],
  loadOptions: async (values, { signal }) => {
    // Forward `signal` so a superseded/unmounted load is cancelled.
    const cities = await fetchCities(values.country, { signal });
    return cities.map(c => ({ label: c.name, value: c.id }));
  },
  debounceMs: 300,
  cacheOptions: true, // reuse the last fetch when a dependency is re-selected
}

cacheOptions (default false) memoizes results per watched-value signature for the field's lifetime, so toggling country back and forth doesn't re-hit the API. Leave it off when the options can change server-side between loads.

Custom Section Render

{
  title: "Payment",
  render: ({ control, disabled }) => (
    <StripeElements>
      <CardElement />
      <FormInput name="billingName" control={control} />
    </StripeElements>
  ),
}

Custom Field Render

{
  name: "avatar",
  type: "file",
  render: ({ field, control, error, fieldId }) => (
    <AvatarUploader fieldId={fieldId} error={error} />
  ),
}

Custom Props

Pass arbitrary props to your field components via customProps:

{
  name: "bio",
  type: "textarea",
  label: "Biography",
  customProps: {
    maxCharacters: 500,
    showCounter: true,
  },
}

Access in your component:

function FormTextarea({ field, customProps, ...props }: FieldComponentProps) {
  const maxChars = customProps?.maxCharacters as number;
  // ...
}

Grouped Select Options

{
  name: "country",
  type: "select",
  options: [
    {
      label: "North America",
      options: [
        { value: "us", label: "United States" },
        { value: "ca", label: "Canada" },
      ],
    },
    {
      label: "Europe",
      options: [
        { value: "uk", label: "United Kingdom" },
        { value: "de", label: "Germany" },
      ],
    },
  ],
}

Schema Builder Utilities

Type-safe helpers for defining schemas outside of components:

import { defineSchema, defineField, defineSection } from "@classytic/formkit/server";

const emailField = defineField<MyFormData>({
  name: "email",
  type: "email",
  label: "Email Address",
  required: true,
});

const personalSection = defineSection<MyFormData>({
  title: "Personal Info",
  cols: 2,
  fields: [emailField],
});

const schema = defineSchema<MyFormData>({
  sections: [personalSection],
});

Type Exports

import type {
  // Core
  FormSchema,
  FormGeneratorProps,
  BaseField,
  Section,

  // Components
  FieldComponentProps,
  FieldComponent,
  ComponentRegistry,

  // Layouts
  SectionLayoutProps,
  GridLayoutProps,
  LayoutComponent,
  LayoutRegistry,

  // Options
  FieldOption,
  FieldOptionGroup,

  // Conditions
  ConditionRule,
  ConditionConfig,
  Condition,

  // Hook types
  UseFormKitOptions,
  UseFormKitReturn,

  // Utility types
  FieldType,
  LayoutType,
  Variant,
  DefineField,
  InferSchemaValues,
  SchemaFieldNames,
  FormElement,
} from "@classytic/formkit";

Browser Support

  • React 19.0+
  • All modern browsers

License

MIT © Classytic