Build Heyflow
KINDAreplaces $102.71/mosaves $1,232.52/yrback to the verdict
A multi-step form builder whose hard problems are immutable published versions so old responses keep their meaning, branching that cannot dead-end, partial answers that survive a closed tab, and delivery that happens once per 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.
- installfree
Why Runs the builder and the public flow in one process.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - have readya few pounds a month
Why The public flow has to be reachable from a phone on a mobile network, and a form only tested on localhost has not been tested.
Get it Any small virtual machine or platform that terminates TLS for you.
Verify
The published form loads on a phone over mobile data - API keyfree tier for low volume
Why Notification and confirmation email is the one integration people notice immediately when it stops.
Get it Any provider you already use, or a local test server while developing.
Verify
A test submission produces a notification you receive - have readyfree
Why Delivering once through timeouts and retries is only testable against a receiver you can make fail on purpose.
Get it A tiny endpoint that logs calls and can be told to time out or return an error.
Verify
It records exactly one call for one submission after a forced timeout - decidefree
Why Each is enforced by a job and asserted by a test, and deciding it after launch means deciding it under pressure.
Get it Write down how long partials, submitted responses and uploaded files are kept, and what deletion means.
Verify
All three appear in configuration and in the README
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- forms(id, name, created_at, archived_at) - form_versions(id, form_id, version, schema_json, published_at, published_by) · immutable once published - fields(version_id, key, ordinal, kind, label, help, required, validation_json, step) · part of the frozen schema - branches(version_id, from_step, condition_json, to_step) · conditions evaluated server side - responses(id, form_id, version_id, started_at, submitted_at, answers_json, partial, source_ref, ip_hash) - deliveries(id, response_id, target, idempotency_key, state, attempts, last_error, delivered_at) - uploads(id, response_id, field_key, path, sha256, mime, bytes, scanned_state) - The version_id on every response is what makes an old answer readable: the response page renders labels from that version, never from the current draft.
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 deliveries. |
UPLOAD_DIR | required | ./data/uploads | Outside the web root, generated names only. |
MAX_UPLOAD_BYTES | required | 10485760 | Enforced before writing, and shown to the visitor as a limit. |
WEBHOOK_SECRETsecret | optional | a long random string | Signs outgoing payloads. The README documents the verification. |
SMTP_URLsecret | optional | smtp://user:pass@host:587 | Notifications. Unset means notifications are off and the form still works. |
PARTIAL_RETENTION_DAYS | required | 30 | Half-finished answers are deleted after this, and the deletion is logged. |
RATE_LIMIT_PER_HOUR | required | 20 | Per hashed address. Rejections are counted, not silent. |
The build, in order
A schema you can freeze
Publishing a form produces an immutable version that responses can point at.
Drafts are mutable, published versions are not.
Files
src/lib/versions.tstests/version-immutable.test.tsThe key is the contract with every export that already exists.
Files
src/lib/schema.tsThis is the difference between a record and a guess.
Files
src/app/responses/[id]/page.tsx
done when · tick each as it passeswatch out- Editing the live form in place feels natural and quietly rewrites history, because a field renamed from 'budget' to 'monthly budget' makes every previous answer read as though it answered the new question. Publish versions, point responses at one, and render each response through its own version.
A flow with no dead ends
Every step is reachable and every path reaches the end.
The browser asks, the server answers.
Files
src/lib/flow.tsUnreachable steps and paths with no ending are publish-time errors.
Files
src/lib/reachability.tstests/reachability.test.tsAnswers off the new path are discarded in view, not silently kept.
Files
src/lib/flow.ts
done when · tick each as it passeswatch out- Evaluating branches in the browser lets someone skip a required step by editing the request, and worse, it means the server cannot tell whether a missing answer was skipped legitimately or not. Decide the next step on the server, and validate on submit that the answers present match the path taken.
Answers that survive a closed tab
A half-finished flow can be resumed, and the partial is stored as a partial.
The page being open is the only time you can rely on.
Files
src/app/api/step/route.tsA completion rate built on mixed rows is a made-up number.
Files
src/lib/responses.tstests/partial-counts.test.tsHalf-finished answers are still personal data.
Files
src/lib/retention.ts
done when · tick each as it passeswatch out- Saving the whole flow on page unload with a keepalive request looks elegant and silently drops long answers, because the body size for keepalive requests is limited to 64 kibibytes and a request over the limit simply returns a network error you cannot distinguish from any other. Save at each step while the page is alive, and treat any unload-time save as a bonus that may not arrive.
Delivery that happens once
A webhook or email goes out once per response, or fails visibly.
The response is saved before any integration is attempted.
Files
src/lib/deliveries.tsOne key per response and target, sent on every attempt.
Files
src/lib/webhook.tstests/delivery-once.test.tsA dead webhook that only shows up in a log is a lost week of leads.
Files
src/app/deliveries/page.tsx
done when · tick each as it passeswatch out- Firing the webhook inside the submit request means a slow receiver holds the page open, a timeout looks like a failure, and the retry delivers the same lead twice to a sales inbox. Write the response, queue the delivery, and let a worker own it.
Uploads, abuse and getting the data outproduct builder
The public form survives contact with the internet and the data can leave.
The client-supplied name never touches the filesystem.
Files
src/lib/uploads.tstests/upload-names.test.tsA rejection you cannot count is a form you cannot tune.
Files
src/lib/abuse.tsA row removed while the file stays is not deletion.
Files
src/lib/responses.ts
done when · tick each as it passeswatch out- Storing an upload under the name the browser supplied puts an attacker-chosen path on your disk and an attacker-chosen extension in front of your web server. Generate the stored name, keep the original name as a label only, and serve downloads through a handler that sets the type yourself.
That is the whole plan for Heyflow. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Payments inside the flow.
- A template and integration catalogue.
- Team accounts and approvals.
- Any deliverability guarantee.
- large integration and template catalog
- compliance controls and guaranteed delivery
- enterprise workflow logic
- spam and abuse defense at scale
- Scheduled exports to a spreadsheet, which reuse the delivery queue rather than adding a second path
- A second delivery target such as a database row, added as a target kind with its own idempotency key
- Per-step drop-off reporting computed from partials, which the data already supports
Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.