Why I rebuilt forms around typed fields

Your form collects a city. Mine collects a place.

That sounds like a small difference. It is the whole difference, and it is why I rebuilt forms around typed fields instead of shipping another box-with-a-regex.

The problem I actually had

An agency inherits a client's form account. The forms work. They collect leads. And they look like somebody else's product with a logo dropped in the corner. The client paid for a brand, and the one surface where they ask a stranger for their email is the one surface that does not carry it.

Then the data arrives, and it is worse. A "city" field holds Berlin, berlin, Berlin, Germany, and Belrin. Somebody exports a CSV, somebody else fixes it by hand, and the CRM slowly fills with strings nobody can query.

Two problems, one root cause: the form is treated as a dumb collector. So I stopped treating it as one.

Typed fields, not validated strings

There are 25 field types. Eleven are the obvious set — text, email, phone, number, date. Six are survey shapes — rating, NPS, opinion scale, picture choice. The eight that matter most are anchored to our data layer, and they are the reason the CSV problem goes away.

A place field returns structured address components, because it is a real places picker and not a text box with autocomplete painted on. A currency field carries its ISO code alongside the number. An address field comes back as street, city, region, postal code and country — five columns, already separated, ready to write.

The difference is not validation. Validation tells you a string looks wrong. A type means the answer arrives in the shape the column already expects.

Logic rules that read like decisions

Chalkboard: a circle labelled ONE ANSWER, a line forking into two branches labelled TWO PATHS, the lower branch ending in a gold square

A rule has a trigger, a condition and an action. The useful part is that the conditions are written the way you would say them out loud:

{
  "when": {
    "left":  { "field": "team_size" },
    "op":    "greater_than",
    "right": { "value": 25 }
  },
  "then": [
    { "jump_to": "book_a_call" }
  ]
}

Twenty operators, all long-form — equal, not_equal, begins_with, contains, greater_than, later_than, answered. There is deliberately no eq alias. A short form fails validation rather than silently matching nothing, because a logic rule that quietly never fires is worse than one that refuses to save.

Variables carry a running score. Hidden fields carry campaign context in from the URL. Recall says the person's own answer back to them later. Together that is the difference between a form and an interview.

Design freedom that cannot break the page

Six layout presets ship in the box — card-light, fullscreen-dark, conversational-left, form-on-image, minimal-print and agency-bold. A preset is a starting point, not a ceiling: every colour, font, radius and spacing value on top of it is editable without writing CSS.

Here is the part I care about more. Design values that come from a client editor or an AI agent are not trusted. They run through a sanitiser that rejects angle brackets, braces, backslashes, semicolons, url(, expression(, javascript: and @import before anything reaches the page. The form renders inside a shadow root, so a hostile value cannot break out, and a well-meaning agent cannot accidentally bleed CSS into the site around it.

Author-owned preset values can carry gradients and images, because those ship in our renderer. User-supplied values cannot. That asymmetry is deliberate, and it is what lets me hand the design controls to an agent without holding my breath.

The CRM write is the point

Chalkboard: a circle labelled ONE SUBMIT splitting into two parallel bars labelled TWO WRITES, the lower bar filled gold

Every field maps to a column. On submit the answer goes to the form's own responses table and into your normalised CRM in the same pass. Each row shows where it got to — pending, written, or failed — and a write that failed for a temporary reason is a replay button, not a support ticket and a re-import.

This is the part that turns a form from a lead-collection widget into part of the data layer. Nothing gets exported. Nothing gets reconciled.

Three surfaces, one definition

Chalkboard: a gold rounded square labelled ONE DEFINITION fanning out to a group of plain rectangles labelled MANY SURFACES

The same form renders three ways: inline on a page of your own site, at its own shareable address on your domain, or inside somebody else's site behind a single script tag. There is nothing to keep in sync, because there is only one form. Change the definition and all three change.

A form is one kind of flow

This is the piece that changed how I think about the whole thing. A form is not a product. It is one kind of flow — the others are booking, funnel, commerce and agent — and they share the field set, the logic engine, the design layer and the address. You can create forms, funnels and bookings today.

Which means the contact form you build this afternoon and the booking page you build next month are not two tools you learn twice. They are the same tool answering a different question.

That is the argument for typed fields, restated: build the primitive properly once, and everything downstream of it gets easier. A place is not a city. A flow is not a form. Getting the type right at the bottom is what stops you paying for it at the top.

You can build one at /dashboard/forms, or have an agent build it from your terminal — the dashboard, the CLI and the MCP tools all write the same shape and validate it the same way.

Publish