Build Jotform

KINDAreplaces $39/mosaves $468/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 two-surface form builder where the hard problems are versioning definitions so old responses still mean what they meant, taking file uploads without opening a hole, and never losing a submission to a failed notification email.

estimated effort weekendthe files for this build are in the project pack

RuntimeNode 22 with Express and better-sqlite3, one processSurfacesAn admin interface bound to localhost, public form pages at a link-only pathDefinitionsImmutable published versions stored as JSONUploadsFiles outside the web root with generated names, served as attachmentsNotificationsAn outbox written in the same transaction as the response

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 both surfaces in one process.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. free tiers exist, otherwise a few pounds a month

    Why The notification path is the one most likely to lose a submission, and it is only testable if you can break it on purpose.

    Get it Any mail provider with SMTP credentials, plus a local capture tool for development.

    Verify A test notification arrives, and pointing at an unreachable host still stores the response

  3. have readyfree, or the cost of the host

    Why The public form has to be reachable while the admin interface stays on localhost, and that separation is the whole security model here.

    Get it Any proxy that can expose one path prefix and nothing else, with TLS.

    Verify The public path answers from outside and the admin path refuses

  4. decidefree

    Why A form collects other people's information and holding it forever by accident is the default outcome unless somebody picks a number now.

    Get it Decide how long a response is useful, write it into the configuration and the README.

    Verify The figure appears in both and a scheduled job honours it

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, created_at, archived_at) · the identity of a form across all its versions
- form_versions(id, form_id, ordinal, definition_json, published_at) · immutable once published, never edited in place
- responses(id, form_version_id, submitted_at, remote_hash, answers_json, status) · remote_hash rather than a raw address, because you do not need the address
- uploads(id, response_id, field_key, stored_name, original_name, mime_detected, bytes, sha256) · stored_name is generated, original_name is decoration
- outbox(id, response_id, kind, attempts, next_attempt_at, last_error, sent_at) · the notification queue, written with the response
- rate_limits(bucket, window_start, count) · per form and per source bucket, because one open form attracts one bored script
- Everything a response needs to be read a year later is reachable from its own row: the version, the answers and the files. Nothing is resolved through the current definition.

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, versions, responses and the outbox.
UPLOAD_DIRrequired./data/uploadsOutside the served tree. Generated names only.
UPLOAD_MAX_MBrequired10Enforced while streaming, not after buffering.
PUBLIC_BASE_URLrequiredhttps://forms.example.comUsed in links and notifications. Only the form path is exposed here.
ADMIN_BINDrequired127.0.0.1:4000Localhost. The admin surface has no login because it has no route in.
SMTP_URLsecretrequiredsmtps://user:redacted@smtp.example.com:465Notifications only. A dead host must not cost you a submission.
RESPONSE_RETENTION_DAYSrequired365Responses and their uploads are removed together on this schedule.
RATE_LIMIT_PER_HOURrequired20Per source bucket per form. Stated in the README so refusals are explainable.

The build, in order

  1. Definitions that are versioned from the first save

    Publishing a form creates an immutable version and responses point at one.

    1. Labels are for humans and change on a whim; keys are for data and never do.

      Files src/forms/schema.jstest/keys.test.js

    2. An immutable version row is what lets an old response still mean something.

      Files src/forms/publish.js

    3. Reading last March's answers through today's labels is how a record becomes fiction.

      Files src/admin/response.js

    done when · tick each as it passes
    watch out
    • Identifying fields by their label means renaming 'Phone' to 'Mobile' orphans every previous answer, and reordering the fields shuffles the columns of an export that somebody has already filed. Give every field a stable key generated at creation, keep the label as display text, and never reuse a key that has been retired.
  2. A public form that validates on the server

    The form works without JavaScript, rejects rubbish server side, and resists a bored script.

    1. Post a malformed body straight at the endpoint as a test, because someone will.

      Files src/public/validate.jstest/direct-post.test.js

    2. It is the cheapest accessibility win and it doubles as the fallback.

      Files src/public/render.js

    3. A captcha sends your respondents to somebody else's service to prove they exist.

      Files src/public/antispam.js

    done when · tick each as it passes
    watch out
    • Validating only in the browser means the first person who posts directly to the endpoint fills your table with whatever they like, and since the form is public that person arrives within days of the link being shared. Treat the browser as a convenience and the server as the only validator, then test by posting a malformed body straight to the endpoint.
  3. Uploads that cannot become a problem

    A file arrives, is bounded and stored inertly, and is never served back as something executable.

    1. Checking the size after buffering is checking after the damage.

      Files src/uploads/stream.jstest/oversize.test.js

    2. The submitted filename is a claim about your filesystem made by a stranger.

      Files src/uploads/store.js

    3. Never hand back a file with a type the uploader chose.

      Files src/admin/download.js

    done when · tick each as it passes
    watch out
    • Buffering the upload and then checking its size means a single request can exhaust memory before the check ever runs, and storing it under the client's filename means a respondent chooses where in your filesystem the file lands and what extension it gets. Enforce the cap while streaming, generate the name yourself, and treat the submitted filename as text to display and nothing else.
  4. A submission that outlives the notification

    The response is committed before anything is emailed, and a mail failure is visible.

    1. The respondent's obligation ends when the row is committed.

      Files src/public/submit.js

    2. A dead mail server should cost you a delay, not a submission.

      Files src/outbox/worker.jstest/outbox.test.js

    3. A queue nobody can see is a queue nobody empties.

      Files src/admin/outbox.js

    done when · tick each as it passes
    watch out
    • Sending the notification before committing, or failing the submission when the mail server refuses, loses the response and shows the respondent an error for something that was entirely your problem. Commit first, queue the mail, and let the thank-you page be honest that the form was received.
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.