Build Formasty
KINDAreplaces $29/mosaves $348/yrback to the verdict
A self-hosted form builder whose hard problems are editing a live form without rewriting the meaning of past answers, submitting from an embed on someone else's site where your cookies do not exist, and accepting files and spam without ruining the disk or the inbox.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the server and the test suite.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - accounta few pounds a month for the machine, plus the domain
Why A form that is down is an enquiry nobody knows they did not receive, and the embed needs a secure origin on a domain you control.
Get it Any small virtual machine plus a domain, with automatic certificate renewal configured.
Verify
The public form loads over https from another network - API keyfree at low volume with most providers
Why Notifications leave through it, and its failures are the ones you will spend the most time diagnosing.
Get it Any transactional mail provider, or a mailbox you already own with application credentials.
Verify
A test submission produces a delivery row with status sent - have readyfree
Why Every cookie assumption in this build only breaks when the form is framed by a site that is not yours, so this is not optional.
Get it Any static page on another domain, or a local server on a different hostname with its own certificate.
Verify
The form submits from it with third party cookies blocked - decidefree
Why Somebody will eventually ask you to delete what they sent, and a retention rule decided afterwards is decided under pressure.
Get it Write down a retention period per form, what deletion removes, and who can trigger it.
Verify
The rule is in the README and enforced by a scheduled job
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- forms(id, slug, title, status, created_at, embed_origins_json) · origins are the sites allowed to frame this form - form_versions(id, form_id, version, schema_json, published_at) · published versions are never edited, only superseded - Fields live inside schema_json with a key generated once at creation, never reused and never derived from the label - submissions(id, form_id, form_version_id, received_at, ip_hash, user_agent, state, spam_score) · state is accepted, quarantined or rejected - answers(submission_id, field_key, value_json) · keyed by the stable field key, never by position in the form - uploads(id, submission_id, field_key, stored_name, original_name, bytes, sha256, declared_type, sniffed_type) - deliveries(id, submission_id, channel, attempt, status, error, at) · every notification attempt, including the failures - A submission row is written before anything is sent anywhere, because it is the only copy of something somebody typed once.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/forms.db | Forms, versions, submissions and answers. Back up with the upload directory. |
UPLOAD_DIR | required | ./data/uploads | Submitted files. Outside any directory that is served or executed. |
TOKEN_SIGNING_KEYsecret | required | hex-64-chars-redacted | Signs the anti-forgery token carried in the form body. Rotating it invalidates open forms. |
SMTP_URLsecret | required | smtps://user:redacted@mail.example.com:465 | Notification transport. Failures appear in the deliveries table. |
MAX_UPLOAD_BYTES | required | 10485760 | Per file. Stated in the rejection message when exceeded. |
MAX_UPLOADS_PER_SUBMISSION | required | 5 | Count cap, checked before anything is written to disk. |
SUBMISSION_RETENTION_DAYS | required | 365 | Deletion removes the submission, its answers and its files. |
The build, in order
A schema that can change without rewriting history
A form can be edited after submissions arrive and every old answer still means what it meant.
The key is the only stable identity an answer has.
Files
src/schema/fields.tstests/field-keys.test.tsA published version is a record of what somebody was shown.
Files
src/schema/versions.tsOtherwise last year's answers arrive under this year's questions.
Files
src/views/submission.ejs
done when · tick each as it passeswatch out- Editing a live form in place and storing answers keyed by field position means renaming a question silently rewrites the meaning of every past answer, and deleting a field shifts every column after it in the export. Generate a key per field once, never reuse a key even after deletion, stamp each submission with its version id, and render history against the version it was captured with.
One write that cannot fail for a stupid reason
A submission is validated server side, written, and acknowledged before anything is sent anywhere.
The browser's copy of your rules is a suggestion.
Files
src/submit/validate.tstests/validate.test.tsAnswers and submission arrive together or not at all.
Files
src/submit/store.tsA mail server having a bad day is not a lost lead.
Files
src/delivery/queue.tssrc/views/admin/deliveries.ejs
done when · tick each as it passeswatch out- Sending the notification inside the request handler means a slow mail server turns a successful submission into a timeout, the person presses submit again, and you end up with either nothing or two of everything. Store first, acknowledge second, deliver third, and make the delivery table show attempts and errors so a silent mail failure is visible rather than assumed.
The embed, where your cookies do not exist
A form embedded on someone else's site submits correctly with third party cookies switched off.
The embed never receives the cookie you were relying on.
Files
src/submit/token.tstests/token.test.tsAnyone can iframe you otherwise, including a phishing page.
Files
src/middleware/headers.tsA wildcard target origin is a broadcast.
Files
public/embed.jssrc/views/embed.ejs
done when · tick each as it passeswatch out- Putting the anti-forgery token in a cookie passes every test you run on your own domain and then fails in the embed, because a cross-site iframe is not sent a cookie defaulted to SameSite Lax, and Chromium's stopgap only sends such a cookie on a top-level cross-site POST when the cookie is under two minutes old, a behaviour its own documentation calls purely temporary. Sign the token into the form body instead, bind it to the form version and an expiry, and verify it with no cookie in the request at all.
- Accepting any postMessage and resizing to whatever it says lets any page on the internet drive your iframe, and sending your height to any listener leaks that the form is open. Check event.origin against the allowlist on receive and pass an explicit target origin on send, never a wildcard.
Files and rubbish, the two things that fill the disk
Uploads are stored safely and obvious spam is held rather than delivered or deleted.
Both the extension and the declared type are attacker supplied.
Files
src/uploads/store.tstests/upload-type.test.tsA generic rejection makes people retry until they give up.
Files
src/uploads/limits.tsYour first false positive will be a real customer.
Files
src/spam/quarantine.ts
done when · tick each as it passeswatch out- The accept attribute on the input and the declared content type both come from whoever is posting, so a file named photo.jpg that is actually a script passes both, and serving the upload directory back under its original name completes the picture. Sniff the bytes, store under a name you generated, serve only through a handler that sets the type you determined, and never place the upload directory inside anything that executes.
- Deleting anything scored as spam means the first false positive is a real enquiry that nobody ever knew arrived. Quarantine instead, show the count, and let a human release a submission, which also gives you the only feedback your scoring will ever get.
Getting the answers out and keeping themproduct builder
Every submission can leave the system, and a restore brings back rows and files together.
One table cannot honestly hold two shapes of form.
Files
src/export/csv.tstests/export.test.tsEither alone restores to a half-working system.
Files
scripts/backup.shtests/restore.test.tsA deleted submission whose photo is still on disk is not deleted.
Files
src/submissions/delete.ts
done when · tick each as it passeswatch out- A single CSV across a form that changed shape either drops the columns added later or blanks them for old rows, and whoever opens it in a spreadsheet will not notice which. Export one file per version by default, and when a combined file is asked for, include a version column and mark a cell that could not exist rather than leaving it empty.
- Backing up the database without the upload directory, or the other way round, gives you a restore where half the submissions point at files that are not there. Take both in one operation with the database's own backup command, and restore the pair into an empty directory in the suite so the pairing is proven rather than assumed.
That is the whole plan for Formasty. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Payment fields and signatures.
- An integration shelf beyond one outbound webhook.
- Hosting forms for other people.
- A template gallery.
- Deliverability: their notification email is warmed and authenticated, your SMTP box is not
- The integration shelf: Sheets, Slack, Notion, CRM, Zapier style webhooks with retries and mapping UI
- Template library and builder polish, which is most of what you are paying for in a form tool
- Payment fields, e-signature, and anything that needs a compliance story like a signed DPA
- Uptime and CDN edge delivery, so a form on a busy page fails silently when your box does
- Partial save and resume for long forms, which needs a token that survives a closed tab without becoming a way to read someone else's draft
- A second notification channel so a mail outage is not a total outage
- Per-field analytics on where people abandon, computed from timing already captured at submit
Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.