Build Heyflow

KINDAreplaces $102.71/mosaves $1,232.52/yrback to the verdict

0%0 of 17 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 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.

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

RuntimeNext.js 15 with TypeScript, one process, server-rendered public flowStorageSQLite with responses written in a single transactionVersioningPublished form versions are immutable rows, drafts are separateLogicBranch conditions evaluated on the server with a reachability check at publishDeliveryA queue with retries, signatures and an idempotency key 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.

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

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

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

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

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

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/forms.dbForms, versions, responses and deliveries.
UPLOAD_DIRrequired./data/uploadsOutside the web root, generated names only.
MAX_UPLOAD_BYTESrequired10485760Enforced before writing, and shown to the visitor as a limit.
WEBHOOK_SECRETsecretoptionala long random stringSigns outgoing payloads. The README documents the verification.
SMTP_URLsecretoptionalsmtp://user:pass@host:587Notifications. Unset means notifications are off and the form still works.
PARTIAL_RETENTION_DAYSrequired30Half-finished answers are deleted after this, and the deletion is logged.
RATE_LIMIT_PER_HOURrequired20Per hashed address. Rejections are counted, not silent.

The build, in order

  1. A schema you can freeze

    Publishing a form produces an immutable version that responses can point at.

    1. Drafts are mutable, published versions are not.

      Files src/lib/versions.tstests/version-immutable.test.ts

    2. The key is the contract with every export that already exists.

      Files src/lib/schema.ts

    3. This is the difference between a record and a guess.

      Files src/app/responses/[id]/page.tsx

    done when · tick each as it passes
    watch 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.
  2. A flow with no dead ends

    Every step is reachable and every path reaches the end.

    1. The browser asks, the server answers.

      Files src/lib/flow.ts

    2. Unreachable steps and paths with no ending are publish-time errors.

      Files src/lib/reachability.tstests/reachability.test.ts

    3. Answers off the new path are discarded in view, not silently kept.

      Files src/lib/flow.ts

    done when · tick each as it passes
    watch 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.
  3. Answers that survive a closed tab

    A half-finished flow can be resumed, and the partial is stored as a partial.

    1. The page being open is the only time you can rely on.

      Files src/app/api/step/route.ts

    2. A completion rate built on mixed rows is a made-up number.

      Files src/lib/responses.tstests/partial-counts.test.ts

    3. Half-finished answers are still personal data.

      Files src/lib/retention.ts

    done when · tick each as it passes
    watch 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.
  4. Delivery that happens once

    A webhook or email goes out once per response, or fails visibly.

    1. The response is saved before any integration is attempted.

      Files src/lib/deliveries.ts

    2. One key per response and target, sent on every attempt.

      Files src/lib/webhook.tstests/delivery-once.test.ts

    3. A 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 passes
    watch 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.
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.