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.
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— itsSchemaFormis genuinely a schema +onSubmitaway. - Minimal boilerplate - once an adapter is registered,
useFormKitwires 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/serverentry 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, andfieldStateprops - Validation helpers -
buildValidationRulesgenerates 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-formQuick 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:
buildValidationRulescompiles a field'spatterninto anew RegExp(...). If your schemas are ever authored by untrusted users, treatpatternas 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) => …, customrender, andloadOptions(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
watchNamesto 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(defaultfalse) memoizes results per watched-value signature for the field's lifetime, so togglingcountryback 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