Build Wufoo
YESreplaces $16.25/mosaves $195/yrback to the verdict
A self-hosted form builder whose hard problems are never losing a submission on any failure path, letting a form's fields change without breaking the responses already collected, and holding back spam without punishing every honest respondent.
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 editor and the public form endpoint.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - have readya few pounds a month
Why Public forms need a stable address people can be sent to, and uploads plus SQLite both need a disk that survives a restart.
Get it Any small virtual machine with a volume, and a domain with a certificate.
Verify
A form submitted from another network is stored and visible in the editor - API keyusually free at small volume
Why Notifications are the part most likely to fail, and the queue exists so that when they do, the response is already safe.
Get it Any provider with a verified sending domain and an API key.
Verify
A notification arrives and the delivery row records the attempt - decidefree
Why This is other people's personal data, and deciding afterwards means deciding it from whatever the code happens to do.
Get it Write down how long submissions are kept per form, who can delete one, and what deletion removes.
Verify
The README states all three and the retention sweep enforces the number
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, schema_version, published_at, closed_at, close_message, retention_days) - fields(id, form_id, key, type, label, required, options_json, position, removed_at) · key is stable and never reused, even after removal - submissions(id, form_id, schema_version, received_at, raw_body, content_type, state, spam_score) · state is stored, flagged, quarantined or deleted - answers(submission_id, field_key, value_text, value_number, value_json) · derived from the raw body and rebuildable from it - uploads(id, submission_id, field_key, filename, bytes, content_type, path, scanned_at) - deliveries(id, submission_id, channel, target, attempts, last_error, delivered_at) · email and webhook attempts, retried outside the request - The answers table can be dropped and rebuilt from raw_body. That is the test of whether the raw body really is being stored first.
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, fields, submissions and deliveries. Back this up before anything else. |
UPLOAD_DIR | required | ./data/uploads | Uploaded files. Deletion removes these along with the row. |
MAX_UPLOAD_BYTES_PER_FIELD | required | 10485760 | Refused before the file is written, not after. |
MAX_UPLOAD_BYTES_PER_SUBMISSION | required | 26214400 | The whole form's ceiling, checked as parts arrive. |
IP_HASH_SALTsecret | required | a long random string, redacted | Salts the hashed client address used for rate limiting. Never commit it. |
MIN_SUBMIT_SECONDS | required | 3 | Anything faster than this from render to submit is quarantined. |
SMTP_URLsecret | optional | smtps://user:redacted@mail.example.com:465 | Notification delivery. Without it the app works and deliveries queue as skipped. |
DEFAULT_RETENTION_DAYS | required | 365 | Applied by the retention sweep unless a form overrides it. |
The build, in order
Store it before you understand it
A submission is durably stored and acknowledged before any validation, email or webhook runs.
Everything after this line is recoverable; everything before it is not.
Files
src/routes/submit.tsIf it can be rebuilt from the raw body, you stored the right thing.
Files
src/submissions/parse.tstests/reparse.test.tsterminalnpm run reparse
A slow mail server should never be visible to the person submitting.
Files
src/delivery/queue.ts
done when · tick each as it passeswatch out- Validating first and storing after means a field you got wrong, a mail server timing out or a crash between the two loses the response entirely, and the person who filled the form has no idea and will not do it twice. Write the raw body first, acknowledge, then do everything else against what is already on disk.
A form that can change its mind
Editing a form's fields never breaks or rewrites the responses already collected.
The label is for humans and changes; the key is for data and does not.
Files
src/forms/fields.tsRendering an old response needs the form as it was that day.
Files
src/forms/versions.tstests/schema-version.test.tsDeleting a question should not delete the answers.
Files
src/forms/fields.ts
done when · tick each as it passeswatch out- Keying answers by the field's label or its position means renaming a question or dragging it up two places silently detaches every answer collected so far, and the response table quietly fills with blanks nobody can explain. Generate a key on creation, never reuse one, and store the schema version with every submission.
Validation that agrees with the browser
Server-side validation accepts everything the browser accepted and explains everything it refuses.
Being stricter than the browser only loses you real people.
Files
src/validate/email.tstests/email-cases.test.tsA single red banner teaches nobody what to fix.
Files
src/validate/form.tsOtherwise the limit is enforced by your disk filling up.
Files
src/uploads/receive.ts
done when · tick each as it passeswatch out- Writing your own email regular expression rejects addresses that exist, and the people it rejects skew towards newer and longer domains who then cannot reach you and never find out why. The HTML specification defines exactly one pattern for the email input type and says openly that it departs from the mail standards on purpose, which is the point: copy that pattern, run it server side, and accept anything the browser accepted.
Spam, without punishing everyone else
Automated submissions are caught and quarantined without putting a puzzle in front of honest people.
Invisible signals cost honest people nothing.
Files
src/spam/rules.tstests/spam.test.tsYour rules will be wrong sometimes and you need the evidence.
Files
src/spam/quarantine.tsYou need rate limiting, not a log of who visited.
Files
src/spam/rate-limit.ts
done when · tick each as it passeswatch out- Reaching for a visual or puzzle challenge as the first line makes the form harder for people using screen readers, older phones and slow connections, and the bots that actually target small forms solve it anyway. Start with the invisible signals, quarantine rather than delete, and read the quarantine for a week before deciding it needs anything stronger.
Getting responses out, and getting them deletedproduct builder
Notifications retry safely, everything exports, and a deletion request is one action that really removes the data.
Row, raw body, answers and files, or it is not deletion.
Files
src/submissions/delete.tstests/delete-really-deletes.test.tsThe error text is how you find out the webhook moved.
Files
src/delivery/retry.tsAn export nobody has reimported is a guess.
Files
src/export/csv.tstests/roundtrip.test.tsterminalnpm run export -- --form=contact npm run import -- --file=contact.json
done when · tick each as it passeswatch out- Deleting a submission row while the uploaded file stays on disk and the raw body sits in a webhook log means you told somebody their data was gone and it was not, which is the one failure here with consequences outside your own project. Make deletion one function that removes the row, the raw body, the derived answers and the files, and write a test that walks the disk afterwards.
That is the whole plan for Wufoo. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Payment fields and card handling.
- Branching logic beyond show and hide.
- A template gallery and theming system.
- Sending from your own mail server.
- enterprise workflow logic
- spam and abuse defense at scale
- large integration and template catalog
- compliance controls and guaranteed delivery
- A per-form webhook signature so receivers can verify the payload came from you
- Partial save for long forms, stored under the same raw-body-first rule
- A summary view per field that reports counts and ranges without exporting the personal data
Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.