Build Wufoo

YESreplaces $16.25/mosaves $195/yrback to the verdict

0%0 of 16 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort one sittingthe files for this build are in the project pack

RuntimeOne Node and TypeScript web application serving both the editor and the public formsStorageSQLite with the raw submission body stored verbatim alongside the parsed answersSchemaVersioned field definitions with stable keys that are never reusedDeliveryA queue with retries for email and webhooks, outside the requestSpamHoneypot, timing and rate limiting, with quarantine rather than deletion

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/forms.dbForms, fields, submissions and deliveries. Back this up before anything else.
UPLOAD_DIRrequired./data/uploadsUploaded files. Deletion removes these along with the row.
MAX_UPLOAD_BYTES_PER_FIELDrequired10485760Refused before the file is written, not after.
MAX_UPLOAD_BYTES_PER_SUBMISSIONrequired26214400The whole form's ceiling, checked as parts arrive.
IP_HASH_SALTsecretrequireda long random string, redactedSalts the hashed client address used for rate limiting. Never commit it.
MIN_SUBMIT_SECONDSrequired3Anything faster than this from render to submit is quarantined.
SMTP_URLsecretoptionalsmtps://user:redacted@mail.example.com:465Notification delivery. Without it the app works and deliveries queue as skipped.
DEFAULT_RETENTION_DAYSrequired365Applied by the retention sweep unless a form overrides it.

The build, in order

  1. Store it before you understand it

    A submission is durably stored and acknowledged before any validation, email or webhook runs.

    1. Everything after this line is recoverable; everything before it is not.

      Files src/routes/submit.ts

    2. If it can be rebuilt from the raw body, you stored the right thing.

      Files src/submissions/parse.tstests/reparse.test.ts

      terminal
      npm run reparse
    3. A slow mail server should never be visible to the person submitting.

      Files src/delivery/queue.ts

    done when · tick each as it passes
    watch 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.
  2. A form that can change its mind

    Editing a form's fields never breaks or rewrites the responses already collected.

    1. The label is for humans and changes; the key is for data and does not.

      Files src/forms/fields.ts

    2. Rendering an old response needs the form as it was that day.

      Files src/forms/versions.tstests/schema-version.test.ts

    3. Deleting a question should not delete the answers.

      Files src/forms/fields.ts

    done when · tick each as it passes
    watch 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.
  3. Validation that agrees with the browser

    Server-side validation accepts everything the browser accepted and explains everything it refuses.

    1. Being stricter than the browser only loses you real people.

      Files src/validate/email.tstests/email-cases.test.ts

    2. A single red banner teaches nobody what to fix.

      Files src/validate/form.ts

    3. Otherwise the limit is enforced by your disk filling up.

      Files src/uploads/receive.ts

    done when · tick each as it passes
    watch 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.
  4. Spam, without punishing everyone else

    Automated submissions are caught and quarantined without putting a puzzle in front of honest people.

    1. Invisible signals cost honest people nothing.

      Files src/spam/rules.tstests/spam.test.ts

    2. Your rules will be wrong sometimes and you need the evidence.

      Files src/spam/quarantine.ts

    3. You need rate limiting, not a log of who visited.

      Files src/spam/rate-limit.ts

    done when · tick each as it passes
    watch 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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.