Polls and aggregation
Aggregate submissions, expose results safely, and render them with FormResults and Poll.
Submissions are a collection, so aggregation is a query. The plugin ships the aggregation utility, a gated public results endpoint, a presentational <FormResults>, and a turnkey <Poll>.
Aggregating
aggregateFieldResponses (one field) and aggregateFormResponses (many fields in one pass) tally submissions by answer:
import { aggregateFieldResponses } from '@10x-media/form-builder'
const results = await aggregateFieldResponses({ payload, formId, field: 'colour' })
// {
// field: 'colour', label: 'Favourite colour', fieldType: 'select',
// total: 42, truncated: false,
// buckets: [
// { value: 'red', label: 'Red', count: 28, percentage: 66.7 },
// { value: 'blue', label: 'Blue', count: 14, percentage: 33.3 },
// ],
// }Semantics worth knowing:
- Percentages are over respondents (submissions that answered the field), not all submissions. Multi-select answers increment one bucket per element while counting one respondent, so summed percentages can exceed 100%.
- Only
completesubmissions count by default; passstatus: 'all'or'partial'to change that. - Labels come from the field's current options, then the submitted snapshot, then the raw value. Buckets follow option order, retired values after, by count.
- The scan is bounded. It pages
payload.findand reduces in JS, so results are identical on Mongo and Postgres, and it stops atmaxSubmissions(default 10000), settingtruncated: trueonly when matching rows exist beyond the cap. Long-term durable rollups are the analytics plugin's territory.
aggregateFormResponses with no fields aggregates every enumerable field (those with options): the survey-summary case.
The results endpoint
Anonymous voters cannot read raw submissions, so public results are served as aggregate counts through a gated endpoint:
GET /api/forms/:id/results?field=<name> -> { results: FieldAggregation[] }Authenticated callers may aggregate any field, or all enumerable fields with field omitted; for a poll-enabled form the results field's effective options (source-resolved, resolveOptions-backed, or authored) are injected too, so a sourced poll renders labels in the admin rather than raw stored values (best-effort: a resolve failure there degrades to raw values rather than blocking the trusted read). Anonymous callers are served only when the form's pollEnabled toggle is on, only when poll.resultsVisibility permits (afterVote, the default, serves any time; afterClose serves only once poll.closesAt has passed), only for the configured poll.resultsField, and only if that field is enumerable; the enumerable check refuses to expose a free-text field publicly even when resultsField is misconfigured. A poll with an option source is enumerable through its resolved options instead: the endpoint resolves the source server-side, seeds buckets in resolved order with the source's labels, forbids results while resolution is empty, and fails closed with a 503 when resolution fails.
poll.resultsField, labelled Vote field in the admin, is authored as a select listing the form's eligible fields: named instances of field types that declare pollEligible (built-in: select, plus country/state when opted in). Server validation enforces the same rule, so a free-text or PII field can never be stored while the poll is enabled; see Poll lifecycle for how enabling a poll binds this field for you.
Every results read also opportunistically resolves a closed, unresolved, non-manual poll before serving, admin or public reads alike; see Scheduled auto-close.
On multi-tenant hosts, add the results.access plugin option: it runs for anonymous requests after the form is loaded and before anything is served, so you can compare the form's tenant against the request's and refuse cross-tenant reads. Authenticated callers bypass it:
formBuilder({
results: {
access: ({ req, form }) => form.tenant === tenantFromRequest(req),
},
})Poll lifecycle
A poll is a form with its top-level Poll checkbox on (pollEnabled on FormDocument and the forms collection alike; there is no more nested poll.enabled). Turning it on reveals a Poll tab holding the Vote field (resultsField), type, resultsVisibility (afterVote | afterClose), and an optional closesAt.
Enabling a poll also binds its vote field on save. With none chosen yet, a form with exactly one poll-eligible named field (a select instance, an opted-in country or state, or your own pollEligible type) auto-selects it; several such fields ask you to pick one ("Choose which field's answers count as votes."); none at all refuses the save ("Add a choice field to use as the poll question."). A poll driven by an option source or the source outcome type is exempt, since it has no on-form vote field to bind.
Once closesAt passes, the server rejects new submissions for that form (submission-time guard, not just UI) and <Poll> renders a closed notice plus the results. type decides how, or whether, the winner gets recorded once the poll closes, defaulting to mostVoted so a freshly enabled poll resolves itself with nothing to configure; see Outcome. The Poll tab always carries a button to close, and later reopen, the poll on demand; see Closing and reopening.
Option sources
By default a poll's choices are the options hand-authored on its results field. When the choices live in your domain data (races, candidates, products), register an option source so authors pick a source instead of retyping options:
import { definePollOptionSource } from '@10x-media/form-builder'
const raceEntrants = definePollOptionSource({
type: 'raceEntrants',
label: 'Race entrants',
config: [{ name: 'raceId', type: 'text', required: true }],
resolve: async ({ config, payload }) => {
const { docs } = await payload.find({
collection: 'entrants',
where: { race: { equals: config.raceId } },
})
return docs.map((doc) => ({ label: doc.name, value: String(doc.id) }))
},
})
formBuilder({ poll: { sources: { raceEntrants } } })With at least one source registered, the poll group gains an optionSource select plus the source's config fields (there are no built-in sources, and hand-authored options remain the default: a source is an explicit per-form opt-in). resolve maps that per-form config to the current options; each option's value is your stable identifier contract, stored in submissions and later matched against the outcome, so it must never change once votes exist.
Resolution happens server-side in two places. At render time you call resolvePollOptions and inject the result via toFormDocument's second argument, so visitors see the host's current choices:
import { resolvePollOptions, toFormDocument } from '@10x-media/form-builder/rsc'
const form = await payload.findByID({ collection: 'forms', id: params.id })
const pollOptions = await resolvePollOptions({ payload, form })
return <Poll form={toFormDocument(form, { pollOptions })} resultsField="choice" />At submission time the server resolves the source again and accepts only the resolved values (fail closed), so a stale or forged option can never be recorded. The results endpoint does the same for anonymous reads, so a sourced poll serves public results end to end without ever storing options on the form.
Custom eligible field types
The eligibility rule extends to your own field types. Declare pollEligible on the definition and the type appears in the vote-field select alongside the built-in select and any opted-in country or state. Its instances still have to yield enumerable answers, and a custom type has two ways to do that.
The direct one is resolveOptions: the type sources its own choices from the instance the author configured, so the options live in the field itself rather than in a separate source. A vote field holding a hasMany relationship to the records being voted on turns the selected ids into options:
import { defineFormField, type FieldTypeOption } from '@10x-media/form-builder'
const athleteVote = defineFormField<'text'>({
type: 'athleteVote',
label: 'Athlete vote',
value: 'text',
pollEligible: true,
config: [
{ name: 'athletes', type: 'relationship', relationTo: 'athletes', hasMany: true, required: true },
],
resolveOptions: async ({ instance, payload, req }) => {
const ids = (Array.isArray(instance.athletes) ? instance.athletes : []).filter(Boolean)
if (ids.length === 0) return []
const { docs } = await payload.find({
collection: 'athletes',
where: { id: { in: ids } },
depth: 0,
req,
})
return docs.map((doc) => ({ value: String(doc.id), label: doc.name }))
},
})
formBuilder({ fields: { athleteVote: athleteVote as FieldTypeOption } })An author adds an athleteVote field, marks a few athletes voteable, and picks it as the results field. That one resolution backs everything: submissions accept only the resolved ids, the public voting UI and the admin winner select offer exactly those athletes, and results are labelled from the same records. No option source is involved; the field is self-sourcing.
resolveOptions is consulted after an optionSource and before the instance's authored options, so the other way is to pair pollEligible with a shared option source when several field types should draw from one domain query instead of each carrying its own.
Outcome
When the real-world result is decided ("which athlete won?"), record it one of two ways; both land in poll.outcome.winningValues (a set, so a tie records every winner), both validate each value against the poll's current options (source-resolved, resolveOptions-backed, or authored), and both stamp resolvedAt server-side.
In the admin. The outcome group's winning-values multi-select loads the poll's effective options from the authenticated GET /api/forms/:id/poll-options endpoint, so an editor opens the form after the race and picks the winner (or several on a tie); clearing the select reopens the outcome. resolvedAt itself stays locked (a beforeChange hook stamps it whenever the winner set is set, changed, or cleared; direct writes are dropped). The group is composable through the poll.outcomeFields seam, which hands you the two default fields (winningValues, resolvedAt) to reorder, wrap, or swap the winner picker for a component of your own (see Customization); membership validation runs server-side regardless, so a swap cannot bypass it. The multi-select shows only for a manual poll; it is hidden entirely for every auto-resolving type — mostVoted, source, and any host definePollType strategy alike — since each of those sets its own outcome when the poll closes, leaving nothing to hand-pick.
The select is document-scoped, so it stays empty on a form that has never been saved: there is no id yet for the endpoint to resolve options against. This costs nothing in practice, since a poll only has a winner after it has run, but it does mean the winner cannot be set in the same breath as creating the form. Save first, then pick.
From domain logic, or automatically by type. Call resolvePollOutcome server-side, explicitly or in auto mode. Explicit mode passes the winning set; auto mode omits it and runs the poll's type strategy, registered via definePollType and selected per form with the poll's type field:
import { resolvePollOutcome } from '@10x-media/form-builder' // also on /rsc
await resolvePollOutcome({ payload, formId, winningValues: ['entrant-42'] }) // explicit
await resolvePollOutcome({ payload, formId, winningValues: ['entrant-42', 'entrant-7'] }) // a tie
const winners = await resolvePollOutcome({ payload, formId }) // auto, via the poll's `type`Poll types
Three types ship, always registered, selected from the poll's type field (default mostVoted, so a freshly enabled poll resolves itself), described as "How the winning option is decided when the poll closes." The select reads Most-voted option wins, Set the winner manually, and Winner from the option source — mostVoted leads the list since it is the default — with the last hidden until at least one poll option source is registered (the strategy itself stays addressable via poll.types regardless):
type | Resolves to |
|---|---|
mostVoted | Whichever choice(s) received the most votes, a tie recording every co-winner. Declines to decide with no responses yet, or when the results aggregation was truncated (a capped sample cannot be trusted to name the true winner). |
manual | Nothing, ever; an admin always sets the winner by hand. |
source | Whatever the poll's option source's own resolveOutcome returns, next to resolve: |
const raceEntrants = definePollOptionSource({
type: 'raceEntrants',
label: 'Race entrants',
config: [{ name: 'raceId', type: 'text', required: true }],
resolve: async ({ config, payload }) => {
const { docs } = await payload.find({
collection: 'entrants',
where: { race: { equals: config.raceId } },
})
return docs.map((doc) => ({ label: doc.name, value: String(doc.id) }))
},
resolveOutcome: async ({ config, payload }) => {
const { docs } = await payload.find({
collection: 'race-results',
where: { race: { equals: config.raceId } },
})
// undefined = not decided yet.
// Return a string for a single winner or a string[] for a tie.
return docs[0] ? String(docs[0].winner) : undefined
},
})Register a strategy of your own with the plugin poll.types option (a strategy keyed to a built-in's type replaces it):
import { definePollType, formBuilder } from '@10x-media/form-builder'
const judgesPick = definePollType({
type: 'judgesPick',
label: 'Judges decide',
resolveOutcome: async ({ payload, form }) => {
const { docs } = await payload.find({
collection: 'judge-scores',
where: { form: { equals: form.id } },
})
return docs[0] ? [String(docs[0].winner)] : undefined
},
})
formBuilder({ poll: { types: { judgesPick } } })resolveOutcome returns the winning value set, or undefined when the outcome is not yet decidable so nothing is written. Auto mode never throws for an undecided result, a poll with no matching strategy, or a source with no resolveOutcome; it throws only when the form itself does not exist or is not poll-enabled, so a scheduled job or a read can call it repeatedly and safely no-op until there is an answer. Either way the call returns the recorded set, so a caller that let the strategy decide still learns the winners. A recorded outcome supersedes the rest of the lifecycle: <Poll> renders a final notice plus results with every winning bucket highlighted (<FormResults winningValues> underneath), regardless of voted or closed state.
Scheduled auto-close
A poll with a closesAt and a non-manual type schedules its own resolution: on every save, the plugin (re)enqueues a native Payload job (waitUntil: closesAt) that runs the type strategy the moment the poll closes, deleting any previously scheduled job for the same form first so the newest closesAt always wins. This needs a wired job runner, config.jobs.autoRun, a worker process, or a serverless cron hitting Payload's run-jobs endpoint, to actually process the job once it comes due.
Without a runner (or if one missed the window), a closed, unresolved, non-manual poll also resolves opportunistically the next time its results are read, by an admin or through the public results endpoint alike: the read resolves first if needed, then serves the now-current results in the same request. The scheduled job and the read-time fallback share one gate (closed, unresolved, not manual), so either firing after the outcome is already recorded is a safe no-op.
Both paths validate against one set, resolveEffectivePollOptions (exported from the root and /rsc): source-resolved options when the poll has an optionSource, the results field type's own resolveOptions when it declares one, otherwise the field's authored options. It backs the admin select, the endpoint, and outcome validation alike, so what the admin offers and what the server accepts cannot drift. Call it directly to render a winner picker of your own.
Closing and reopening
The Poll tab always carries one button, toggling between Close poll now and Reopen poll depending on whether closesAt currently holds any value at all — a past date, a still-future scheduled one, or one the button itself just set. Clicking it saves the whole document (through the admin's own submit, the same as saving any other change) with an override of the live poll group, rather than calling a dedicated endpoint:
- Close stamps
closesAtto now. Because the save carries whatever else the admin has open in the form, amanualpoll's winner, even one picked in the same sitting and never separately saved, persists together with the close in one request. Amanualpoll needs that winner picked before Close enables at all (the hint reads "Select a winning value first." until then);mostVotedandsourcestay enabled with no winner picked, since the usual poll-close machinery resolves them once the save'safterChangehook runs. - Reopen appears once
closesAtholds a value, and clears it together withpoll.outcome.winningValuesin the same save, un-deciding the poll (any type,manualincluded) so voting, and later re-resolution, can start again.
An authenticated POST /api/forms/:id/close endpoint backs the identical close semantics for a programmatic caller (a cron, a script): it refuses an already-closed poll and a manual poll with no winner recorded, then resolves the outcome itself for mostVoted/source. The admin button no longer calls this endpoint; saving the document directly means a manual winner just picked never risks needing a separate save first.
Rendering results
<FormResults> is presentational and never fetches; resolve data and pass it in. Labels, counts, and percentages are real text; the bar fill is decorative. Import ./styles.css for the fb-results* styling or style it yourself (a shadcn parity component is in the registry):
import { FormResults } from '@10x-media/form-builder/react'
<FormResults results={results} /> // one field or an arrayfetchFormResults({ formId, field }) is the client helper for the endpoint.
<Poll>
Renders <Form> while the poll is open and unvoted, then fetches and shows results. A per-browser localStorage flag skips straight to results on revisit; past closesAt it shows a closed notice plus results, a voted-but-open afterClose poll shows a wait notice instead of fetching, and a recorded outcome shows the final notice with the winner highlighted:
import { Poll } from '@10x-media/form-builder/react'
<Poll form={form} resultsField="colour" />The localStorage guard is UX, not integrity: it stops the same browser re-seeing the form and nothing more. Server-enforced one-per-identity composes from what already ships: gate on req.user for authenticated forms, use the notAlreadySubmitted validation rule to dedupe by a field value (such as email), and rate limiting caps anonymous repeat voting per identity.
SSR voted state
The plugin option poll: { votedCookie: true } sets an httpOnly fb-voted-{formId}=1 cookie on every successful submission to a poll-enabled form. Being httpOnly it is invisible to client JS; read it server-side with hasVotedCookie and pass the result to <Poll hasVoted>. hasVoted: true ORs with the localStorage guard (either marks the visitor as voted); false or omitted just falls back to localStorage, so passing the cookie read unconditionally is always safe:
import { hasVotedCookie, toFormDocument } from '@10x-media/form-builder/rsc'
import { Poll } from '@10x-media/form-builder/react'
import { headers } from 'next/headers'
export default async function PollPage({ params }) {
const form = await payload.findByID({ collection: 'forms', id: params.id })
const voted = hasVotedCookie((await headers()).get('cookie'), form.id)
return <Poll form={toFormDocument(form)} resultsField="colour" hasVoted={voted} />
}The cookie is HttpOnly; SameSite=Lax with a one-year Max-Age, and deliberately not Secure: it is a UX marker only (skip re-showing a form the visitor already answered), never an integrity or auth signal, and omitting Secure keeps it working on plain-HTTP local development. Do not build any server-side trust on its presence; one-per-identity enforcement is the previous section's territory.