Build SurveySparrow

KINDAback 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 self-hosted survey tool whose hard problems are editing a live survey without rewriting the meaning of yesterday's answers, never losing or double counting a submission from an open public endpoint, and reporting standard metrics by their real definitions with the base count attached.

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

RuntimeNext.js with the response endpoint as the only publicly reachable surfaceStorageSQLite, responses immutable once submittedVersioningSurveys frozen on publish, every version retained, responses pinned to oneSubmissionA per response token with idempotent submit and server side partial savesAbusePer link caps, address rate limits and a payload size limit, no hidden trap fields

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 application and the response endpoint.

    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 response endpoint has to be reachable by respondents, which also means reachable by everyone else, which is what phase four is about.

    Get it A small VPS or any platform that will run a Node process with a persistent disk for SQLite.

    Verify A share link opens from a phone on mobile data

  3. decidefree

    Why Both are asserted by tests, and choosing them after seeing the data means choosing whatever is convenient.

    Get it Write down how long responses live, what an anonymous link collects, and what the response cap is by default.

    Verify All three appear in the configuration and are named in the tests

  4. have readyfree

    Why Resume, idempotency and the partial save path only fail in the ways that matter when a real person closes a laptop mid survey.

    Get it Ask three people to answer a long survey on their own devices, and ask one to abandon it halfway.

    Verify One response resumes from a partial and one was submitted twice without duplicating

  5. API keyfree or pennies

    Why Only for notifying yourself that responses arrived; sending to a list is explicitly out of scope.

    Get it Any mail account with SMTP access, or leave it unset and use the interface.

    Verify A test notification arrives in your own inbox

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- surveys(id, title, created_at, archived_at)
- survey_versions(id, survey_id, version, schema_json, published_at, closed_at, response_cap) · immutable once published
- questions inside schema_json(id, kind, prompt, required, options_json, scale_min, scale_max) · question ids stay stable across versions
- responses(id, survey_version_id, token, started_at, submitted_at, partial_json, answers_json, locale) · submitted rows are never updated
- answers inside answers_json(question_id, value, skipped) · keyed by the stable question id, never by position
- events(id, response_id, kind, at, reason) · started, resumed, rejected, submitted, with the reason a rejection happened
- Because question ids are stable, a chart can follow one question across three versions and mark honestly where the wording changed.

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/surveys.dbSurveys, versions, responses and events.
PUBLIC_BASE_URLrequiredhttps://surveys.example.comUsed to build share links. Wrong here means links that go nowhere.
ADMIN_PASSWORDsecretrequireda long random stringThe only login. The response endpoint is the only thing outside it.
RESPONSE_TOKEN_SECRETsecretrequiredsk-...redactedSigns response tokens, which carry idempotency and resume.
RATE_LIMIT_PER_HOURrequired60Per address, per link. Rejections are recorded with a reason.
MAX_PAYLOAD_BYTESrequired65536Refused before parsing rather than after.
RETENTION_DAYSrequired730Responses are deleted past this, and the count is logged.
SMTP_URLsecretoptionalsmtps://user:pass@smtp.example.com:465Optional, and only for notifying yourself. Unset means the interface only.

The build, in order

  1. A survey you can edit without rewriting history

    Publishing freezes a version and every response points at exactly one.

    1. A published version is read only for the rest of its life.

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

    2. Otherwise answers are keyed to a position that moved.

      Files src/schema.ts

    3. The number is meaningless without knowing which question produced it.

      Files src/app/admin/page.tsx

    done when · tick each as it passes
    watch out
    • Editing the wording of a live question in place means last week's answers now sit under a question nobody was asked, and this is discovered in the meeting where the chart is presented. Freeze on publish, version on edit, keep question ids stable, and show the version beside every count.
  2. A response that survives a closed laptop

    A half finished response resumes and a submitted one is never duplicated.

    1. Server side, because the browser is where the answers get lost.

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

    2. A retry after a timeout is the common case, not the rare one.

      Files src/submit.tstests/idempotent-submit.test.ts

    3. A response you refused is still something you need to be able to explain.

      Files src/events.ts

    done when · tick each as it passes
    watch out
    • Holding partial answers only in the browser loses a ten minute survey to a tab crash and nobody starts again, while accepting a retried submission as a fresh row inflates every count by however many people had a flaky connection. Persist partials against the token and make submit idempotent on that token.
  3. Numbers with a definition and a base

    Every figure reported carries its calculation and how many people it came from.

    1. The definition is public and the wrong calculation is obvious to anyone who knows it.

      Files src/scoring.tstests/nps.test.ts

    2. Eleven responses and eleven thousand should not look the same.

      Files src/components/Figure.tsx

    3. A wording change is a discontinuity, so draw it as one.

      Files src/charts.ts

    done when · tick each as it passes
    watch out
    • Reporting a score from eleven responses as one number with a decimal place invites a decision it cannot carry, and computing a promoter score as the average of the zero to ten answers gives a figure in the wrong range that nobody can reconcile with anyone else's. Compute from the bands, show the base, and round to a whole number.
  4. An open endpoint on the public internet

    Collecting without accounts survives being discovered.

    1. The simplest defence is a survey that is closed.

      Files src/limits.ts

    2. Refuse the body you have not read rather than the object you built from it.

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

    3. A respondent who thinks they submitted and did not is worse than one who was told no.

      Files src/app/respond/page.tsx

    done when · tick each as it passes
    watch out
    • An uncapped public form is a free database write for whoever finds it, and the reflex fix is a hidden field that penalises anyone using a screen reader or a password manager. Cap the link, rate limit by address and token, record why each rejection happened, and keep every anti abuse measure visible to the respondent.
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.