Build Opinion Stage
YESreplaces $25/mosaves $300/yrback to the verdict
A form, poll and quiz builder whose hard problems are surviving inside an iframe on somebody else's site without cookies or a known height, never losing a submission to a failed notification, and reporting results that are honest about being self-selected.
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, the public form and the notification worker in one process.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - have readya few pounds a month plus the domain
Why The form is embedded on other people's pages over a secure connection, so it needs a stable public address.
Get it Any small virtual server with automatic certificates.
Verify
The form loads in an iframe on a test page over a secure connection - API keyfree tiers exist; check the current limits before relying on one
Why Notifications are the one outbound dependency, and it must be able to fail without costing a response.
Get it Any transactional sending service or an SMTP account you control.
Verify
A test notification arrives and a deliberate failure leaves the response intact - have readyfree
Why Height messaging, the framing header and blocked third-party storage are only testable from a genuinely different origin.
Get it Any static page on a second domain or a local server on another port.
Verify
The embed resizes correctly and an unlisted origin is refused - decidefree
Why Each becomes a test and a line in the privacy notice, and choosing them afterwards means choosing what the code already did.
Get it Write down how long responses are kept, the minimum count for showing a percentage, and what makes a submission spam.
Verify
All three are in configuration and named in tests
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- forms(id, slug, kind, title, intro, thanks_text, status, allowed_origins_json, created_at) · kind is poll, quiz or survey - questions(id, form_id, ordinal, kind, label, help_text, required, options_json, autocomplete_token, branch_json) - responses(id, form_id, client_key, started_at, submitted_at, completed, user_agent_family, spam_reason) · client_key makes a retry idempotent - answers(id, response_id, question_id, value_text, value_number, option_ids_json) · one row per answered question, so partials are real data - quiz_keys(question_id, option_id, correct, points) · an answer key, which is the only kind of quiz score with a basis - notifications(id, response_id, kind, attempts, last_error, delivered_at) · retried separately, never blocking the response - views(form_id, day, loads, starts, submissions) · counted without cookies, for a drop-off funnel rather than for tracking people - The response row is committed before anything else happens. Everything downstream is a retryable side effect.
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, responses and answers. The responses are the only irreplaceable part. |
PUBLIC_BASE_URL | required | https://forms.example.com | Used in embed snippets and in the framing header. |
MAIL_API_KEYsecret | optional | re_...redacted | Owner notifications only. A failure here never affects a stored response. |
NOTIFY_TO | required | you@example.com | Where submission notifications go. The values themselves are not in the log. |
UPLOAD_DIR | required | ./data/uploads | Files attached to responses. Deleted with the response and by retention. |
RESPONSE_RETENTION_DAYS | required | 365 | Stated in the privacy notice on the form and enforced by a job. |
MIN_COUNT_FOR_PERCENT | required | 20 | Below this, the report shows counts only and says why. |
The build, in order
A form people can actually complete
Every question is properly labelled, keyboard-operable and sized for a thumb.
A placeholder is an example, not a name.
Files
src/render/question.jstests/a11y.test.jsIt is one attribute and it satisfies a published criterion.
Files
src/render/question.jsMeasured, because a thumb is bigger than a mouse pointer.
Files
tests/a11y.test.js
done when · tick each as it passeswatch out- Placeholder text instead of a label looks clean and disappears the moment somebody types, so a respondent who is interrupted comes back to a form of unlabelled boxes, and a screen reader user never had a label at all. Use a real label element for every control and treat the placeholder as an example, not a name.
A submission that cannot be lost or doubled
A response survives a failed notification, a double click and a flaky connection.
This ordering is the entire guarantee.
Files
src/submit.jstests/submit.test.jsDouble clicks and retries are the normal case.
Files
src/submit.jsA mail outage should not cost you an answer.
Files
src/notify.jstests/notify.test.js
done when · tick each as it passeswatch out- Sending the owner email inside the request handler and rolling back when it fails throws away the answer because a mail server was briefly down, and the respondent saw an error and will not return. Commit the row, then queue the notification with its own retries, and show the respondent the thank-you screen either way.
Living inside someone else's page
The embed sizes itself correctly, talks only to origins you allow, and does not rely on storage it will not get.
An unchecked message handler is somebody else's script talking to yours.
Files
src/public/embed.jssrc/public/host-snippet.jsSo your form does not appear on a site you have never heard of.
Files
src/headers.jsFor a large share of visitors it already does not.
Files
src/session.jstests/no-storage.test.js
done when · tick each as it passeswatch out- Preventing repeat responses with a cookie set inside the iframe quietly does nothing in browsers that block third-party storage, so the protection appears to work in your testing and is absent for a large share of real visitors. Decide what one response per person actually means for you, implement it server-side where you can, and state plainly in the report that an embedded public form cannot enforce it.
Results that say what they are
Every figure carries its count, and nothing implies a sample of a population.
A percentage without an n is a rumour with a decimal point.
Files
src/report.jsBelow a threshold the number misleads more than it informs.
Files
src/report.jstests/report.test.jsThe report is what gets shared.
Files
src/templates/report.html
done when · tick each as it passeswatch out- A results page showing a clean percentage split with no count invites the number to be screenshotted and quoted, and by then nobody knows it came from eleven answers on a blog sidebar. Print the count next to every percentage, suppress percentages below a minimum, and put the self-selection sentence in the report rather than in the documentation.
Getting the answers out, and letting them goproduct builder
Responses export cleanly, expire on a stated schedule, and one person's data can be removed.
Commas and newlines are in every long answer.
Files
src/export.jstests/csv.test.jsAn orphan file is a data protection problem with no owner.
Files
src/retention.jsWhere the person deciding whether to answer can see it.
Files
src/templates/form.html
done when · tick each as it passeswatch out- A CSV built by joining answers with commas breaks the first time somebody types a comma or presses return in a long-answer box, and the file opens misaligned in a spreadsheet with nobody noticing which columns shifted. Use a real CSV writer, test with a value containing a comma, a quote and a newline, and open the result in a spreadsheet once by hand.
That is the whole plan for Opinion Stage. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Payment collection.
- A large template library.
- Third-party spam challenge services.
- Cross-form visitor tracking and lead scoring.
- templates, hosted delivery, lead features, and engagement reporting
- advanced branching
- payment and CRM integrations
- large template libraries
- enterprise compliance
- Conditional branching beyond one level, once the funnel makes the drop-off points obvious
- A results page you can share publicly with a token, reusing the suppression rules
- Scheduled closing of a form at a date, which is one field and a job
Need the files? The project pack on the verdict page hands your agent the whole brief · more forms.