Skip to content

Forms

Louise can review submissions (the Inquiries tab) and now build the form behind them. defineForm is one definition that drives the submission table, the public capture route, server + client validation, and the review columns—no hand-rolled POST handler, columns, or validation per site. inquiries is just the built-in default form.

import { defineForm } from "louise-toolkit/forms";
export const contact = defineForm({
name: "inquiries", // form + table name (a bare SQL identifier)
fields: {
firstName: { type: "text", label: "First name" },
lastName: { type: "text", label: "Last name" },
email: { type: "email", label: "Email", required: true },
regarding: { type: "select", label: "Regarding", options: ["General", "Booking", "Press"] },
message: { type: "textarea", label: "Message", required: true, validation: (r) => r.max(5000) },
},
spam: { turnstile: true, rateLimit: { max: 5, windowSec: 60 } },
});

Field type is text | email | tel | url | textarea | number | select | checkbox | date. required makes the column NOT NULL and adds a required check. validation reuses the shared Rule builder—the same engine the content collections use, so there’s one validation definition.

The result carries everything derived from the fields:

  • contact.table / contact.columns—the Drizzle table (spread columns into your own sqliteTable to add site columns like a clientId). Generate the migration with drizzle-kit as usual.
  • contact.reviewColumns{ key, label, type }[] for the submissions panel.

formRoute is the public companion to the editor-gated review route. It’s same-origin-guarded (CSRF) but not session-gated—anyone may submit—validates + coerces against the fields, applies the spam guard, and inserts:

import { formRoute } from "louise-toolkit/editor";
import { composeWorker } from "louise-toolkit/worker";
import { contact } from "./forms";
export default composeWorker({
routes: [
formRoute({
form: contact,
// Optional spam wiring, used only when the form declares it:
rateLimitKv: (env) => env.RL, // KV for the fixed-window limiter
turnstileSecret: (env) => env.TURNSTILE_SECRET,
}),
// …your review route + SSR fallthrough
],
});

Mounted at /api/louise/forms/<name> by default. A submission returns 201 { ok: true }; a validation failure returns 422 { error, violations } (per-field { path, message }); a rate-limited request returns 429 with Retry-After; a failed Turnstile or cross-origin request returns 403. Unknown keys in the body are ignored—only declared fields are read and stored.

A plain HTML <form method="POST"> works out of the box (it sends Referer, so the same-origin check passes); a fetch with a JSON body works too.

louise-toolkit/client ships a headless <Form> that renders accessible inputs from the catalog and mirrors the exact server validation client-side (it reuses validateSubmission—the same Rule engine, no second definition), then POSTs to the form’s formRoute. It’s unstyled by default (every element has a louise-form* class hook), so a site keeps its own look.

import { Form } from "louise-toolkit/client";
import { contact } from "./forms"; // a client-safe { name, fields } config
<Form form={contact} />; // POSTs to /api/louise/forms/inquiries

Pass a plain { name, fields } config to the client (not the defineForm result, which carries the Drizzle table—keep that server-side). For a non-Solid site, mountForm(hostEl, { form }) renders into a DOM node and returns a disposer. A file field uploads through the media route and stores the returned URL. On a 422 the server’s per-field messages are painted back onto the inputs.

The base <Form> covers flat, generated forms with no dependency. For a multi-step form, field arrays, or async cross-field rules, reach for @tanstack/solid-form—and still validate with Louise’s one Rule engine via the dependency-free adapter:

import { tanstackFormValidators } from "louise-toolkit/forms";
const v = tanstackFormValidators(contact); // { [field]: ({ value }) => error | undefined }
// wire each into a TanStack field — note the ASYNC slot:
<form.Field name="email" validators={{ onChangeAsync: v.email }}>
{/* … */}
</form.Field>;

tanstackFormValidators (and per-field tanstackFieldValidator) return functions in TanStack Form’s validator shape, backed by validateField—so a complex hand-built form runs the same checks as <Form> and the server.

The map is flat, mirroring FormConfig.fieldsdefineForm has no array or nested field type, because each field is one column. A form with repeating rows builds the array with TanStack’s own API and attaches these validators to the leaves.

There is no generated solid-form component, on purpose. Since defineForm is flat, anything generated from it would be a flat form—which is what <Form> already renders, with no dependency. The reason to reach for TanStack is the shapes defineForm can’t express, and there the form is yours to build; what Louise contributes is the validators, so your client checks and your server checks stay one definition.

If you do build one with array fields, key the rows by a stable id rather than by index. Removing a row currently marks the shifted siblings isTouched (form#2131), and since most UIs only show an error once a field is touched, that surfaces a validation error against a row the person never edited.

The submissions review route (inquiriesRoute) and the Settings’ Inquiries tab are already form-agnostic: the route lists newest-first / deletes by id over the form’s table, and the panel renders the columns. So one defineForm gives you capture and review with no extra wiring.

All guards are opt-in per form. The visible ones are enforced only when formRoute is given the matching binding:

  • Rate limit—a KV fixed-window limiter (reuses security/rate-limit), keyed by CF-Connecting-IP by default. Fails open (a limiter outage never blocks submissions).
  • Turnstile—server-side token verification (verifyTurnstileToken) of the cf-turnstile-response field. Fails closed. See the turnstile setup path for the widget.

Two silent heuristics reject a likely bot with a fake success (so it can’t tune) and never insert:

  • spam.honeypot: "website"—a decoy field a bot fills but a human never sees. The <Form> helper emits it hidden + autocomplete="off".
  • spam.minSeconds: 2—a minimum time between render and submit. The helper stamps a louise_ts at mount; a plain HTML form that doesn’t stamp one is not penalized.

Declare where a submission is announced; formRoute fires them off the response path (waitUntil), so a slow webhook/mail never delays the visitor:

defineForm({
name: "inquiries",
fields: {/* … */},
notify: { webhook: env.SLACK_WEBHOOK, email: { to: "hello@studio.com" } },
});

webhook POSTs { form, values }. email uses a mailer you pass to formRoute (wrap your EMAIL binding + louise-toolkit/email templates), so Louise stays decoupled from any one mail transport. A notification failure never fails the submission.

A catalog of forms (no new table each time)

Section titled “A catalog of forms (no new table each time)”

A first-class form like inquiries gets its own typed table. For one-off forms—RSVP, waitlist, booking—write to the shared submissions table (louise-toolkit/db) instead, so a new form needs no migration:

formRoute({ form: rsvp, genericTable: "submissions" }); // capture → { form, data }
submissionsRoute({ form: "rsvp", resolveEditor }); // review that form's rows

formRoute’s genericTable stores each submission as { form, data } (the values JSON-encoded); submissionsRoute lists/deletes one form’s rows (parsing data back out) for Louise Settings review tab. Register a tab per catalog form and each gets capture, validation, and review with one shared table.