Build Jotform
KINDAreplaces $39/mosaves $468/yrback to the verdict
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.
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 both surfaces in one process.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - accountfree 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/forms.db | Forms, versions, responses and the outbox. |
UPLOAD_DIR | required | ./data/uploads | Outside the served tree. Generated names only. |
UPLOAD_MAX_MB | required | 10 | Enforced while streaming, not after buffering. |
PUBLIC_BASE_URL | required | https://forms.example.com | Used in links and notifications. Only the form path is exposed here. |
ADMIN_BIND | required | 127.0.0.1:4000 | Localhost. The admin surface has no login because it has no route in. |
SMTP_URLsecret | required | smtps://user:redacted@smtp.example.com:465 | Notifications only. A dead host must not cost you a submission. |
RESPONSE_RETENTION_DAYS | required | 365 | Responses and their uploads are removed together on this schedule. |
RATE_LIMIT_PER_HOUR | required | 20 | Per source bucket per form. Stated in the README so refusals are explainable. |
The build, in order
Definitions that are versioned from the first save
Publishing a form creates an immutable version and responses point at one.
Labels are for humans and change on a whim; keys are for data and never do.
Files
src/forms/schema.jstest/keys.test.jsAn immutable version row is what lets an old response still mean something.
Files
src/forms/publish.jsReading 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 passeswatch 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.
A public form that validates on the server
The form works without JavaScript, rejects rubbish server side, and resists a bored script.
Post a malformed body straight at the endpoint as a test, because someone will.
Files
src/public/validate.jstest/direct-post.test.jsIt is the cheapest accessibility win and it doubles as the fallback.
Files
src/public/render.jsA captcha sends your respondents to somebody else's service to prove they exist.
Files
src/public/antispam.js
done when · tick each as it passeswatch 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.
Uploads that cannot become a problem
A file arrives, is bounded and stored inertly, and is never served back as something executable.
Checking the size after buffering is checking after the damage.
Files
src/uploads/stream.jstest/oversize.test.jsThe submitted filename is a claim about your filesystem made by a stranger.
Files
src/uploads/store.jsNever hand back a file with a type the uploader chose.
Files
src/admin/download.js
done when · tick each as it passeswatch 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.
A submission that outlives the notification
The response is committed before anything is emailed, and a mail failure is visible.
The respondent's obligation ends when the row is committed.
Files
src/public/submit.jsA dead mail server should cost you a delay, not a submission.
Files
src/outbox/worker.jstest/outbox.test.jsA queue nobody can see is a queue nobody empties.
Files
src/admin/outbox.js
done when · tick each as it passeswatch 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.
Getting responses out without hurting anyoneproduct builder
Export is safe to open, deletion is real, and the form can be closed.
The characters and the save-and-reopen caveat are both published; follow them.
Files
src/export/csv.jstest/csv-injection.test.jsAn orphaned upload is a data breach waiting for a directory listing.
Files
src/admin/delete.jsA closed form still holds evidence somebody may need.
Files
src/forms/close.js
done when · tick each as it passeswatch out- A CSV cell is executed as a formula by spreadsheet software when it begins with certain characters, and a form is an invitation for a stranger to choose those characters. OWASP names equals, plus, minus, at, tab, carriage return and line feed, plus full-width variants, and warns that Excel can strip quoting when a file is saved and reopened so that a previously escaped formula becomes live again; its current advice is to prefix an offending cell with a tab character inside the quoted field. Do that, and test the export by opening it.
That is the whole plan for Jotform. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Payments of any kind.
- PDF generation and approvals.
- A widget catalogue beyond the seven field types.
- Accounts, teams and permissions.
- widget catalog
- PDF/approval workflows
- payments
- app builder
- integrations
- compliance options
- templates
- Conditional fields, which are a definition feature and change nothing about storage
- A per-form webhook, once the outbox has proven itself on mail
- Partial save and resume, which needs a token model and deserves its own thinking
Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.