Build SurveySparrow
KINDAback to the verdict
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.
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 application and the response endpoint.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/surveys.db | Surveys, versions, responses and events. |
PUBLIC_BASE_URL | required | https://surveys.example.com | Used to build share links. Wrong here means links that go nowhere. |
ADMIN_PASSWORDsecret | required | a long random string | The only login. The response endpoint is the only thing outside it. |
RESPONSE_TOKEN_SECRETsecret | required | sk-...redacted | Signs response tokens, which carry idempotency and resume. |
RATE_LIMIT_PER_HOUR | required | 60 | Per address, per link. Rejections are recorded with a reason. |
MAX_PAYLOAD_BYTES | required | 65536 | Refused before parsing rather than after. |
RETENTION_DAYS | required | 730 | Responses are deleted past this, and the count is logged. |
SMTP_URLsecret | optional | smtps://user:pass@smtp.example.com:465 | Optional, and only for notifying yourself. Unset means the interface only. |
The build, in order
A survey you can edit without rewriting history
Publishing freezes a version and every response points at exactly one.
A published version is read only for the rest of its life.
Files
src/versions.tstests/version-immutable.test.tsOtherwise answers are keyed to a position that moved.
Files
src/schema.tsThe number is meaningless without knowing which question produced it.
Files
src/app/admin/page.tsx
done when · tick each as it passeswatch 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.
A response that survives a closed laptop
A half finished response resumes and a submitted one is never duplicated.
Server side, because the browser is where the answers get lost.
Files
src/app/api/respond/route.tsA retry after a timeout is the common case, not the rare one.
Files
src/submit.tstests/idempotent-submit.test.tsA response you refused is still something you need to be able to explain.
Files
src/events.ts
done when · tick each as it passeswatch 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.
Numbers with a definition and a base
Every figure reported carries its calculation and how many people it came from.
The definition is public and the wrong calculation is obvious to anyone who knows it.
Files
src/scoring.tstests/nps.test.tsEleven responses and eleven thousand should not look the same.
Files
src/components/Figure.tsxA wording change is a discontinuity, so draw it as one.
Files
src/charts.ts
done when · tick each as it passeswatch 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.
An open endpoint on the public internet
Collecting without accounts survives being discovered.
The simplest defence is a survey that is closed.
Files
src/limits.tsRefuse the body you have not read rather than the object you built from it.
Files
src/app/api/respond/route.tsA 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 passeswatch 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.
Answers you can take away, and promises you can keepproduct builder
Export, retention and the anonymity claim all mean exactly what they say.
Column positions change between versions; ids do not.
Files
src/export.tstests/export-roundtrip.test.tsNot a display decision, and not a deletion after the fact.
Files
src/anonymity.tstests/no-address-stored.test.tsA retention setting nobody has watched delete anything is a setting, not a policy.
Files
src/retention.ts
done when · tick each as it passeswatch out- Telling respondents a survey is anonymous while the web server writes their address to an access log makes the promise false in a way that only surfaces when somebody asks for the logs. Decide anonymity per link, and when it is on, do not collect the address anywhere in the stack rather than collecting and discarding it.
That is the whole plan for SurveySparrow. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Bulk sending to a contact list.
- Employee engagement and 360 workflows.
- An integration catalogue.
- Respondent file uploads.
- enterprise workflow logic
- spam and abuse defense at scale
- large integration and template catalog
- compliance controls and guaranteed delivery
- One question per screen as a presentation option, which is a rendering change and nothing more
- Branching logic expressed inside the frozen schema, so a branch is versioned like everything else
- A public results page per version, once you are sure what the base counts say
Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.