Form
@voltage/form handles the full lifecycle of an HTML form: parsing, CSRF protection, Zod validation, and error display. Errors are sent back as HTMX out-of-band swaps and appear next to the relevant fields without a page reload. Per-field validation fires as the user types. None of this requires custom JavaScript on your end.
Installation
Section titled “Installation”yarn add @voltage/form @voltage/htmx @voltage/intl @voltage/jsx @voltage/zod zodBasic usage
Section titled “Basic usage”forRoot()
Section titled “forRoot()”import { FormModule } from '@voltage/form';
@Module({ imports: [ FormModule.forRoot({ secret: process.env.CSRF_SECRET, }), ],})export class AppModule {}forRootAsync()
Section titled “forRootAsync()”FormModule.forRootAsync({ inject: [AppConfiguration], useFactory: (config: AppConfiguration) => ({ secret: config.csrfSecret, }),})Defining a form
Section titled “Defining a form”A form is a class that extends the Form() factory. Pass a Zod schema and the factory attaches the static helpers described below:
import { Form } from '@voltage/form';import { z } from 'zod';
export class LoginForm extends Form({ schema: z.object({ email: z.string().email().trim().toLowerCase(), password: z.string().min(8).max(64).trim(), remember: z.stringbool().default(false), }),}) {}Controller setup
Section titled “Controller setup”Decorate the handler with @FormHandler() and inject validated data with @FormData(). The interceptor runs validation before the method body executes — if the form is invalid, the handler is never called:
import { FormHandler, FormData } from '@voltage/form';import { LoginForm } from './login.form';
@Controller('auth')export class AuthController { constructor( private readonly htmx: HTMX, private readonly url: URLGenerator, ) {}
@Post('login') @FormHandler(LoginForm) async login(@FormData() data: LoginForm) { const user = await this.users.findByEmail(data.email);
if (user === null || !this.auth.verify(data.password, user.password)) { LoginForm.throw('email', 'Invalid email or password'); }
return this.htmx.redirect(await this.url.generate(DashboardController)); }}JSX helpers
Section titled “JSX helpers”Form.action(), Form.input(), and Form.error() return JSX attribute objects. Spread them onto the corresponding elements:
@Injectable()export class LoginView extends InjectableComponent { constructor(private readonly url: URLGenerator) { super(); }
render() { const { action, input, error } = LoginForm;
return ( <form {...action(this.url.generate(AuthController, 'login'))}> <div> <input type="email" {...input('email')} /> <span {...error('email')} /> </div> <div> <input type="password" {...input('password')} /> <span {...error('password')} /> </div> <button type="submit">Log in</button> </form> ); }}Form.action(url) wires the form for HTMX submission: sets the action URL, injects the CSRF token, and configures the swap strategy.
Form.input(name, value?) wires the input for real-time validation: posts to the form action on change (debounced 500 ms), sends a validate header with the field name, and clears the error container when the user resumes typing.
Form.error(name) returns a data-form-error attribute that the interceptor uses as the OOB swap target when that field fails validation.
Loading indicators
Section titled “Loading indicators”Add hx-indicator to the form to show a loading state while the submission is in flight. HTMX adds the htmx-request class to the indicator element during the request:
render() { const { action, input, error } = LoginForm;
return ( <form {...action(this.url.generate(AuthController, 'login'))} hx-indicator="#login-spinner"> <input type="email" {...input('email')} /> <span {...error('email')} /> <input type="password" {...input('password')} /> <span {...error('password')} /> <button type="submit"> Log in <span id="login-spinner" class="opacity-0 htmx-request:opacity-100">…</span> </button> </form> );}Nested fields
Section titled “Nested fields”Field names follow dot notation. Form.error() converts dots to dashes in the data-form-error attribute automatically:
<input {...input('address.street')} /><span {...error('address.street')} />{/* renders data-form-error="address-street" */}Validation
Section titled “Validation”Real-time
Section titled “Real-time”As the user types, each input posts the full form body to the same action URL with a validate: fieldName header. The interceptor validates the field in isolation and returns an OOB swap. If validation passes, the error container is cleared. If it fails, the error message is written into it.
On submit
Section titled “On submit”On full form submission, the interceptor validates all fields. Fields that fail get individual OOB swaps; fields that pass are cleared. The handler body only runs when every field is valid.
Custom server-side validation
Section titled “Custom server-side validation”Call Form.throw() anywhere in the handler to surface errors that Zod cannot express — uniqueness checks, cross-field rules, or business logic.
Pass a field path and one or more messages to throw a single-field error:
@Post()@FormHandler(RegisterForm)async register(@FormData() data: RegisterForm) { if (data.password !== data.passwordConfirm) { RegisterForm.throw('passwordConfirm', 'Passwords do not match'); }
const taken = await this.users.exists({ email: data.email }); if (taken) { RegisterForm.throw('email', 'This email is already registered'); }
// ...}Pass an object to throw errors on multiple fields at once:
RegisterForm.throw({ passwordConfirm: 'Passwords do not match', email: 'This email is already registered',});Values can be a string or an array of strings for multiple issues on the same field.
Error strings are plain text — pass them through intl.formatMessage() for translated messages:
import { defineMessages } from '@voltage/intl';
const messages = defineMessages({ emailTaken: { id: 'register.email.taken', defaultMessage: 'This email is already registered' }, passwordMismatch: { id: 'register.password.mismatch', defaultMessage: 'Passwords do not match' },});
@Post()@FormHandler(RegisterForm)async register(@FormData() data: RegisterForm) { if (data.password !== data.passwordConfirm) { RegisterForm.throw('passwordConfirm', this.intl.formatMessage(messages.passwordMismatch)); }
const taken = await this.users.exists({ email: data.email }); if (taken) { RegisterForm.throw('email', this.intl.formatMessage(messages.emailTaken)); }
// ...}Message customization
Section titled “Message customization”Validation messages are resolved in this order, from most to least specific:
messageson the form class, keyed by'field.issueCode'(e.g.'email.invalid_format')messageson the form class, keyed by field name only (e.g.'email')defaults.messagesinFormModule.forRoot(), keyed by Zod issue code- Zod’s built-in message for that issue code
Messages must be wrapped with defineMessage or defineMessages so formatjs extract can find them during the extraction step.
Define form-level messages in the Form() call:
import { Form } from '@voltage/form';import { defineMessages } from '@voltage/intl';import { z } from 'zod';
const messages = defineMessages({ emailInvalidFormat: { id: 'register.email.invalid_format', defaultMessage: 'Enter a valid email address' }, passwordTooSmall: { id: 'register.password.too_small', defaultMessage: 'Password must be at least 8 characters' },});
export class RegisterForm extends Form({ schema: z.object({ email: z.string().email(), password: z.string().min(8), }), messages: { 'email.invalid_format': messages.emailInvalidFormat, 'password.too_small': messages.passwordTooSmall, },}) {}Define module-level defaults that apply to every form:
import { defineMessages } from '@voltage/intl';
const messages = defineMessages({ invalidFormat: { id: 'validation.invalid_format', defaultMessage: 'Invalid format' }, tooSmall: { id: 'validation.too_small', defaultMessage: 'Value is too short' },});
FormModule.forRoot({ secret: process.env.CSRF_SECRET, defaults: { messages: { invalid_format: messages.invalidFormat, too_small: messages.tooSmall, }, },})File uploads
Section titled “File uploads”Add z.instanceof(File) to the schema. The interceptor parses the multipart body with busboy, detects the MIME type from the file’s magic bytes, and for images re-encodes through sharp to strip metadata and normalize orientation:
export class AvatarForm extends Form({ schema: z.object({ avatar: z.instanceof(File), bio: z.string().max(500).trim().optional(), }),}) {}
@Post('avatar')@FormHandler(AvatarForm)async upload(@FormData() data: AvatarForm) { const { avatar } = data;
await this.storage.save(avatar);
return htmx({ trigger: 'avatar:updated' });}The JSX component sets enctype alongside the spread from action():
@Injectable()export class AvatarView extends InjectableComponent { constructor(private readonly url: URLGenerator) { super(); }
render() { const { action, input, error } = AvatarForm;
return ( <form {...action(this.url.generate(AvatarController, 'upload'))} enctype="multipart/form-data"> <input type="file" {...input('avatar')} /> <span {...error('avatar')} /> <input type="text" {...input('bio')} /> <span {...error('bio')} /> <button type="submit">Upload</button> </form> ); }}Limit file size per form with the limits option:
export class AvatarForm extends Form({ schema: z.object({ avatar: z.instanceof(File), }), limits: { fileSize: 2_000_000, // 2 MB },}) {}Global defaults apply when no per-form limit is set:
FormModule.forRoot({ secret: process.env.CSRF_SECRET, defaults: { limits: { fileSize: 5_000_000, }, },})CSRF protection
Section titled “CSRF protection”CSRF protection is automatic. Form.action() includes a __CSRF_TOKEN__ placeholder in the HTMX headers. The form middleware replaces it with a real token before the response is sent, bound to the current session. The interceptor validates the token on every submission — no setup beyond forRoot() is required.
Because the middleware sets Cache-Control: no-store on any response that contains a CSRF token, forms are never served from cache.
Configuration
Section titled “Configuration”interface FormConfiguration { /** Secret used to sign CSRF tokens. Use a long, random string in production. */ secret: string; defaults?: { /** Default busboy multipart limits applied to all forms unless overridden per form. */ limits?: { fieldNameSize?: number; fieldSize?: number; fields?: number; /** Maximum file size in bytes. */ fileSize?: number; files?: number; parts?: number; headerPairs?: number; }; /** Default validation messages applied when no form-level message matches. */ messages?: Partial<Record<ZodIssueCode, MessageDescriptor>>; };}