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>[&locale=<code>] -> { results: FieldAggregation[] }locale selects the language of the option labels, clamped like a submission locale; absent, labels come back in your default locale.
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),
},
})Vote store
By default, a poll's results are not read by scanning submissions. Every submission that reaches complete for a poll-enabled form bumps a row in a hidden form-poll-votes collection (renamable through poll.votes.overrides.slug, see Renaming collections): one counter per (form, field, value), plus a shared respondents counter per (form, field). Results reads then sum those counters directly, an O(options) lookup instead of a submission scan, so a poll's results never truncate and never slow down as submissions pile up.
Privacy. A tally row carries no per-voter data, only a value and a running count. Pair poll.votes (on by default) with the form's persistSubmissions: false and a poll can count votes while storing zero per-visitor information: nothing links a count back to who cast it, because nothing about the voter was ever kept.
Permanence. Votes are counted, never uncounted. Deleting a submission in the admin, editing one, or a persistSubmissions: false prune after actions run, none of them decrement the tally: once a vote is counted it stays counted. This is what lets a persist-off poll keep an accurate count with no submissions on disk to recount from. The one deliberate exception is a poll with allowChange on: there, updating an already-complete submission's answer moves its vote to the new value (the old value decrements, the new one increments, respondents stay put); delete and prune remain permanent even then.
Healing drift. Because the tally is append-only and separate from submissions, it can theoretically drift from what the submissions collection holds (a direct database edit, a restore from backup). recountPollVotes({ payload, formId }) rebuilds a form's tally from scratch: it scans the form's submissions, deletes the existing tally rows for the results field, and replays the scan into fresh rows. It throws rather than write a knowingly partial count when the scan itself would be truncated (more than 100,000 submissions).
That scan-and-replace shape would be unsafe on a persistSubmissions: false poll: with submissions pruned there would be nothing to replay, so the delete step would wipe the tally down to zero instead of leaving it alone. recountPollVotes refuses this case before it deletes anything: a form with persistSubmissions off throws immediately (its tally is the only record of its votes), so the store is never touched. Recounting works only for a poll that keeps its submissions.
Turning it off. poll.votes: false removes the hidden collection entirely and restores scan-based results (the submission scan aggregateFieldResponses already described in Aggregating). With the store off, a poll has no counters that outlive its submissions, so the server refuses to save a poll-enabled form with persistSubmissions: false: nothing would be left to count. Turn the store back on, or keep submissions persisted, to lift the guard.
Survey reads still scan. The tally only backs a poll's own results field, read on its own: the authenticated all-fields survey aggregation (aggregateFormResponses with no field, or an admin reading every enumerable field at once) always scans submissions, tally or not, since it has no notion of "the poll field" to special-case. For a persistSubmissions: false poll this means the two views diverge on purpose: the survey-style aggregation shows fewer submissions, or none, once pruning has run, while <Poll> and the results endpoint reading that field by name show the true, permanent tally. Read poll results through the poll's own results read, not the general survey aggregation, when submissions are not persisted.
Reading the store directly. A vote's count is spread across up to VOTE_SHARDS (8) rows per (form, field, value), so that concurrent votes for the same popular option do not serialize on one document. A reader going straight to the form-poll-votes collection (rather than through aggregateFromVotes, which already does this) must sum count across every row sharing a (form, field, value), not just read one row. The reserved value: '' (exported as RESPONDENTS_VALUE) holds the field's total respondent count, also sharded, and is never a real answer since an empty value is never counted as a vote. POLL_VOTES_SLUG, RESPONDENTS_VALUE, VOTE_SHARDS, aggregateFromVotes, and recountPollVotes are all exported from the root. POLL_VOTES_SLUG is the default slug; on a host that renamed the store, read collectionSlugsOf(payload).pollVotes instead.
import { aggregateFromVotes, recountPollVotes, resolveEffectivePollOptions } from '@10x-media/form-builder'
// Tally-backed results for one field, zero-seeded from its resolved options.
const form = await payload.findByID({ collection: 'forms', id: formId, depth: 0 })
const options = await resolveEffectivePollOptions({ payload, form })
const results = await aggregateFromVotes({ payload, formId, field: 'colour', options })
// Rebuild a form's tally from its stored submissions after out-of-band drift.
await recountPollVotes({ payload, formId })poll.votes also takes an object to reach the hidden collection's overrides (the same CollectionOverrides shape as collection overrides); its indexes stay fixed, since the unique compound index is load-bearing for the atomic upsert-increment behind every vote bump.
formBuilder({
poll: {
votes: false, // scan-based results; a poll form must keep persistSubmissions on
},
})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: npx shadcn@latest add https://docs.10xmedia.de/r/form-builder/form-results.json):
import { FormResults } from '@10x-media/form-builder/react'
<FormResults results={results} /> // one field or an arrayfetchFormResults({ formId, field, locale }) 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" /><Poll> takes every <Form> prop. Its submissionLocale is also sent with the results request, so a German voter sees German option labels in the results as well as in the form.
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.
Custom results rendering
A results row often needs data only your app holds. A poll over people wants a photo and a properly ordered name next to each count; the aggregation carries { label, value, count, percentage } and nothing else, and CSS cannot add an <img> for data that never reaches the DOM. renderResults replaces the built-in <FormResults> wherever the poll shows results (after voting, when closed, and for a recorded outcome), so each bucket's value can key into whatever the host knows:
<Poll
form={form}
resultsField="athlete"
renderResults={({ results, currentValues }) => {
const list = Array.isArray(results) ? results : [results]
return list.map((field) =>
field.buckets.map((bucket) => {
const athlete = athletesByCode[bucket.value]
return (
<AthleteRow
key={bucket.value}
photo={athlete?.headshot}
name={athlete?.displayName}
count={bucket.count}
percentage={bucket.percentage}
isYourVote={currentValues?.includes(bucket.value)}
/>
)
})
)
}}
/>The callback receives exactly what <FormResults> receives at that site (FormResultsProps: results, plus winningValues on a finalized poll, currentValues when the voter's pick is known, t, locale), so the default is equivalent to (props) => <FormResults {...props} />. Wrapping instead of replacing works the same way: render <FormResults {...props} /> inside your own layout, or vary by state (a podium once winningValues is present, bars before). The chrome around the results stays the poll's own: the closed and final notices, the wait notice, the error state, and the change-vote button all render regardless of what the callback returns.
Vote storage
localStorage is only the default backing for the voted flag. The adapters prop redirects it to your own store (cookies, IndexedDB, a state manager, a consent-gated wrapper), or turns it off entirely:
// Custom store: read and write receive the resolved storageKey (default fb-poll-{form.id}).
<Poll
form={form}
resultsField="colour"
adapters={{ voteStorage: { read: (key) => myStore.has(key), write: (key) => myStore.add(key) } }}
/>
// No client persistence: the server-set voted cookie is the single source of truth.
<Poll form={form} resultsField="colour" hasVoted={voted} adapters={{ voteStorage: false }} />voteStorage: false performs neither the read nor the write; hasVoted still marks the visitor as voted, so pairing it with the votedCookie option (next section) drops the weaker duplicate of state the server already owns.
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} />
}poll.cookiePrefix renames the cookie (for example acme-voted- gives acme-voted-{formId}). The helpers cannot see the plugin config on their own, so pass payload whenever the prefix is set: hasVotedCookie(cookieHeader, form.id, payload) and votedCookieName(form.id, payload). Without it they read the default name. resolveVotedSubmission already takes payload and needs nothing extra.
The cookie is HttpOnly; SameSite=Lax with a one-year Max-Age. The 1 marker is 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.
On an allowChange poll the cookie's value is a signed submission id instead of 1 (set even without the votedCookie option); hasVotedCookie reads both forms identically. Unlike the marker, the signed id is a bearer capability, whoever holds it can change that vote, so it belongs on HTTPS: the plugin adds Secure to it whenever your admin user collection's auth.cookies.secure is on, mirroring how you already transport Payload's own auth cookies. Payload defaults that setting to off (which is what keeps plain-HTTP local development working); set auth: { cookies: { secure: true } } in production so the vote capability never crosses the wire in clear.
Changeable votes
The Allow changing votes checkbox on the Poll tab (poll.allowChange, default off) lets a visitor revise their vote while the poll is open. With it off nothing changes anywhere; with it on:
- One submission per voter, updated in place. The voted cookie carries the voter's submission id, signed with the Payload secret so only ids the server issued are accepted (a forged or tampered cookie is simply treated as a new voter, never someone else's vote). A repeat submit from that browser, to the same
form-submissionsendpoint the first vote used, updates the existing submission instead of creating another: the submission count for the form does not grow. - The tally follows. Changing A to B decrements A's counter and increments B's; the respondents count is untouched. An unchanged re-submit is a no-op. See the permanence note under Vote store.
- Closing still wins. Past
closesAta change is rejected exactly like a new vote, and the tally stays as last voted. - Same guards as a vote. The change runs the full create pipeline: field validation against the poll's effective options, spam controls (rate limit, honeypot, captcha), and consent recapture. The
notAlreadySubmittedrule excludes the voter's own submission, so keeping the same answer never collides with itself. - Cookie-scoped by design. Clearing cookies (or a different device) makes the browser a new voter; that is the intended granularity for an anonymous fan vote, not a bug. Pair with server-enforced one-per-identity dedup when you need more.
- Requires stored submissions. Changing updates the voter's stored row, so
allowChangecannot combine withpersistSubmissions: false: a pruned submission would turn the next "change" into a second, double-counted vote. The server rejects the combination at save time (either direction: enablingallowChangeon a persist-off form, or turning persistence off on anallowChangepoll).
Showing and changing the current pick
resolveVotedSubmission is the server-side read of "what did this browser vote": it verifies the cookie, loads the submission, and returns { submissionId, value, pick } (value is the stored answer verbatim for prefilling, pick the normalized string values for highlighting), or null for anything unusable, absent cookie, legacy 1 marker, pruned submission, wrong form. Hand it to <Poll currentVote>:
import { resolveVotedSubmission, 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 = await resolveVotedSubmission({
payload,
cookieHeader: (await headers()).get('cookie'),
formId: form.id,
})
return (
<Poll
form={toFormDocument(form)}
resultsField="colour"
hasVoted={voted != null}
currentVote={voted}
/>
)
}With currentVote set, <Poll> treats the visitor as voted, marks their option in the results (fb-results__bucket--yours row class plus a translated "Your vote" badge, also available directly as <FormResults currentValues>), and, while an allowChange poll is open, renders a Change vote button (fb-poll__change) that reopens the form prefilled with their current answer. After the change it returns to results with the marker on the new option.
The button appears only when a change would actually be a change: with a resolved currentVote, or right after an in-session vote (the server just issued the signed cookie). A localStorage-only voted flag proves nothing about the httpOnly cookie, and a "change" without one would silently create a second submission, so on a plain revisit a client-only host shows results without the button; pass the server-resolved currentVote to offer changing across visits. The VotedSubmission type is exported from the root, /rsc, and /react.