TL;DR: FormJet takes form submissions from any HTML form — no JS framework required, just a <form action="https://formjet.app/f/xxx" method="POST"> — and turns them into webhooks, emails, or a spreadsheet row. The one decision that shaped everything else: I don't ask you to define your form's fields ahead of time. Here's why that's harder than it sounds and why I still think it's right.
The typical form-backend model
Most form-as-a-service products want you to define a schema first: field names, types, validation rules, all configured in a dashboard before your form can submit anything. That's a reasonable design if the product's job is validation. It's a bad fit if the product's job is "get out of the way."
I wanted someone to be able to write a plain HTML form with whatever name attributes make sense to them, point the action at FormJet, and have it just work — including if they add a field next month without touching any dashboard.
What "schema-less" actually means in the code
Every submission is stored as-is, keyed by whatever field names the form used:
async function handleSubmission(formId: string, body: Record<string, string>) {
const submission = {
formId,
fields: body, // no validation against a predefined schema
receivedAt: new Date(),
};
await db.submissions.insert(submission);
await dispatchWebhooks(formId, submission);
}
The webhook payload mirrors whatever was submitted, field-for-field. If someone's HTML form has name="email" this week and they rename it to name="contact_email" next month, FormJet doesn't reject the change or need reconfiguration — it just reflects whatever showed up.
The cost of not having a schema
This is where I have to be honest about the trade-off, because it's real: no schema means no server-side validation. If a form is missing its email field entirely because of a typo in the HTML, FormJet stores a submission without an email field — silently, from FormJet's point of view. It doesn't know that field was supposed to exist.
I handled this with an opt-in layer rather than making it mandatory: users can define expected fields after the fact, purely for their own alerting (get notified if 10 submissions in a row are missing a field you usually see), without that layer ever blocking a submission from being accepted. Validation became an observability feature, not a gatekeeper.
async function checkFieldDrift(formId: string) {
const recentSubmissions = await db.submissions.recent(formId, 10);
const expectedFields = await db.expectedFields.get(formId);
const missingRate = calculateMissingRate(recentSubmissions, expectedFields);
if (missingRate > 0.5) await notifyOwner(formId, expectedFields);
}
Why I'd make the same call again
The alternative — required schema, strict validation — optimizes for data quality at the cost of the thing that makes a form backend actually convenient: you can change your HTML without going anywhere else to update a config. For a solo developer or small team wiring up a contact form or a waitlist signup, that convenience is the entire pitch. The moment FormJet starts rejecting submissions because a field doesn't match a schema, it's stopped being "drop this in and forget about it" and started being one more system to configure.
If you're building a developer tool and torn between strict-by-default and permissive-by-default, it's worth asking which failure mode your users would rather debug: a silently missing field, or a submission that got rejected and the person on the other end never knew.
FormJet is free for low volume — link's in my profile.
Top comments (1)
Interesting design choice. Not storing the schema keeps the backend simple and means any HTML form works, at the cost of server-side validation and typed exports. How do you handle a field being renamed halfway through? Do old and new submissions just live side by side? I work on chatform.in, which sits at the opposite end (the schema drives a conversational form with follow-up questions), so it's fun to see the minimal approach argued well.