@lacspace/form
v1.0.0
Published
End-to-end form handling for the server — turn FormData into typed, validated data with a honeypot + timing spam guard, and get back your data or per-field errors ready to re-render. Shaped for Next.js Server Actions. Zero-dependency, isomorphic.
Maintainers
Readme
@lacspace/form
Typed, validated, spam-protected form handling — built for Next.js Server Actions.
Take a
FormData, validate it against a schema, block bots with a honeypot + timing check, and get back either your typed data or per-field errors ready to re-render. Framework-agnostic, zero dependencies.
Install
npm i @lacspace/form @lacspace/validate@lacspace/validate is optional — any object with a safeParse (including zod) works.
Use it — a complete contact form
// app/actions.ts
"use server";
import { createForm } from "@lacspace/form";
import { v } from "@lacspace/validate";
const contact = createForm({
schema: v.object({
name: v.string().min(2),
email: v.string().email(),
message: v.string().min(10),
}),
honeypot: "company", // hidden field bots fill; humans never see it
minSubmitMs: 800, // reject sub-second (bot-speed) submissions
});
export async function submit(prev: unknown, formData: FormData) {
const r = contact.action(prev, formData);
if (!r.ok) return r; // { errors, values } → re-render form
await sendEmail(r.data); // ✅ { name, email, message } fully typed
return { ok: true as const };
}// app/contact/page.tsx
"use client";
import { useActionState } from "react";
import { submit } from "../actions";
import { honeypotProps, timestampValue } from "@lacspace/form";
export default function Contact() {
const [state, action] = useActionState(submit, null);
return (
<form action={action}>
<input name="name" defaultValue={state?.values?.name as string} />
{state?.errors?.name && <p>{state.errors.name}</p>}
<input name="email" defaultValue={state?.values?.email as string} />
{state?.errors?.email && <p>{state.errors.email}</p>}
<textarea name="message" defaultValue={state?.values?.message as string} />
{state?.errors?.message && <p>{state.errors.message}</p>}
{/* spam protection — one line each */}
<input {...honeypotProps("company")} />
<input type="hidden" name="_ts" defaultValue={timestampValue()} />
<button>Send</button>
{state?.ok && <p>Thanks — we'll be in touch!</p>}
</form>
);
}What you get
createForm(opts)→{ handle, action }—actionmatches the(prev, formData)shape ofuseActionState, so it drops in with zero glue.- Typed result —
{ ok: true, data }or{ ok: false, errors, values, spam? }.valuesechoes what the user typed so re-renders keep their input. formDataToObject(fd)— repeated keys → arrays, files passed through, empty strings preserved.- Spam guard —
honeypotfield +minSubmitMstiming heuristic, both optional. Internal fields (_ts, honeypot) are stripped before validation so your schema can stay.strict(). honeypotProps(name)+timestampValue()— client helpers, no React dependency.
Pairs with @lacspace/validate, @lacspace/rate-limit and @lacspace/mailer.
Licensing
Free under the Lacspace Free Licence — MIT-equivalent freedoms. Use it in personal and commercial projects at no cost; just keep the notice. See the Lacspace Licence Centre.
Part of the Lacspace ecosystem — zero-dependency, isomorphic TypeScript packages.
