How this is built, and what I would build for you
The brief asks for a stack recommendation before it asks for a build, so this page answers that first. Everything described here is running in the prototype you just clicked through — the schema below is the schema it uses.
Put the data in Postgres and treat the questionnaire as versioned content. The front end is then a small decision; the data model is not.
Your stated hard problem — persistent question IDs, so history maps perfectly across years — is a relational modelling problem. Whatever tool draws the screens, that model has to be right on day one, because it is the only part you cannot fix later without touching every answer you hold. Fifty-eight companies filling in ninety questions once a year is a trivial load; nothing here needs to scale, it needs to stay correct for a decade.
One honest note on the brief: the requirement as written is not a no-code requirement. A no-code builder can draw these screens, but none of them will hold the versioning model without an external database underneath. My recommendation is to accept that and get the small amount of code that matters, rather than pay a builder's constraints to avoid it.
The platforms your brief names, assessed against your requirement
Next.js + Supabase
recommendedBuild once, own the code. ~£0–25/mo hosting at this scale.The versioning model is ordinary relational work in Postgres, and row-level security enforces per-company isolation in the database rather than in application logic. Exports are SQL. Nothing about 58 companies × 90 questions × once a year strains it.
Against: It is code. You need someone available for the annual cycle change — though that is a two-hour job once the cycle editor exists, and it is the same person either way.
Supabase + Retool for the admin half
credible alternativeRetool from ~$10/user/mo, and only programme staff need seats.Retool is genuinely good at the internal-tool half: the member dashboard, the cycle editor, the exports. It sits straight on top of the same Postgres schema, so the data model does not change at all.
Against: The respondent-facing half still has to be built — Retool is not where you put a form 58 external companies fill in. So this splits the work rather than removing it, and you maintain two front ends.
Bubble
workable, not advised here$32–134/mo plus build time.Bubble can express the screens. If the programme were a flat annual form it would be the obvious answer.
Against: The requirement you led with — versioned questions with persistent IDs and a lineage table — is a relational modelling problem, and Bubble's data layer is the part that fights hardest. Connecting Bubble to an external Postgres to get the model right means paying Bubble's constraints for none of its convenience.
Lovable (or similar AI app builders)
good accelerator, poor owner~$25–50/mo during build.Generates a React + Supabase app quickly, and you keep the code, which matters for something that has to run every year for a decade.
Against: It is fast at the 80% that is not hard here and unreliable at the 20% that is — carry-forward rules, lineage edges, RLS policies. Fine as a scaffolding step; not something to hand the annual programme to unattended.
Softr
front end only~$50–150/mo depending on plan and user count.Softr over Airtable or Supabase would give you the member-facing screens quickly, and it is genuinely easy for a non-developer to change afterwards.
Against: It builds forms over a table, not a versioned questionnaire. The pre-fill logic, the lineage edges and the impact preview all have to live somewhere Softr cannot reach, so you end up maintaining a Softr front end plus custom backend logic — two things instead of one.
Staying on SurveyMonkey
the honest baselineWhat you pay now.It collects answers, and if pre-fill were not required it would be hard to justify replacing.
Against: Pre-fill from last year's response is the requirement it cannot meet, and answers keyed on question text mean every rewrite silently breaks the series. That is the actual problem, and it gets worse each year the programme runs.
Five tables carry the whole requirement
questions holds identity. question_versions holds wording, one row per year. responses points at the identity, never the wording. question_lineage records splits and merges. Everything else is ordinary.
-- ─── identity ────────────────────────────────────────────────────────────────
create table companies (
id text primary key, -- stable slug, used in exports
name text not null,
country text not null,
tier text not null check (tier in ('brand','tier1','tier2')),
joined_cycle int not null
);
create table cycles (
year int primary key,
status text not null check (status in ('draft','open','closed')),
opens date not null,
closes date not null
);
-- ─── the questionnaire ───────────────────────────────────────────────────────
-- One row per question, ever. The id is chosen once and never changes.
-- Everything that a respondent sees lives in question_versions instead.
create table questions (
id text primary key, -- e.g. 'ENR.COAL.PHASEOUT'
section text not null references sections(id),
introduced_in int not null references cycles(year),
retired_in int references cycles(year) -- null while still asked
);
-- One row per (question, cycle). Inserting here is how the form changes.
-- Nothing in this table is ever updated after its cycle closes.
create table question_versions (
question_id text not null references questions(id),
cycle_year int not null references cycles(year),
prompt text not null,
answer_type text not null check (answer_type in
('boolean','select','multiselect','number','percent',
'currency','year','text','longtext')),
options jsonb, -- ordered, for select types
unit text,
help text,
required boolean not null default true,
position int not null,
-- Conditional logic, versioned with the question: {"tiers": [...]} or
-- {"qid": "...", "equals": [...]}. Who sees a question can change between
-- cycles without touching a single stored answer.
show_if jsonb,
primary key (question_id, cycle_year)
);
-- Transformations a question_id match cannot express: a split, a merge, or a
-- wholesale replacement. Dated to the cycle the change takes effect in.
create table question_lineage (
id bigserial primary key,
cycle_year int not null references cycles(year),
kind text not null check (kind in ('split','merge','replace')),
from_ids text[] not null,
to_ids text[] not null,
note text not null
);
-- ─── the answers ─────────────────────────────────────────────────────────────
-- The only table that matters in five years. Keyed on the permanent question
-- id, never on wording or position, which is what makes a rewrite survivable.
create table responses (
company_id text not null references companies(id),
cycle_year int not null references cycles(year),
question_id text not null references questions(id),
value jsonb,
status text not null default 'answered'
check (status in ('answered','carried','confirmed','blank')),
updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id),
primary key (company_id, cycle_year, question_id)
);
create index responses_by_question on responses (question_id, cycle_year);
create table submissions (
company_id text not null references companies(id),
cycle_year int not null references cycles(year),
state text not null default 'not_started'
check (state in ('not_started','in_progress','submitted')),
submitted_at timestamptz,
submitted_by uuid references auth.users(id),
primary key (company_id, cycle_year)
);
-- Append-only. Answers change; the record of who changed them does not.
create table response_audit (
id bigserial primary key,
company_id text not null,
cycle_year int not null,
question_id text not null,
old_value jsonb,
new_value jsonb,
changed_by uuid,
changed_at timestamptz not null default now()
);
-- ─── who can see what ────────────────────────────────────────────────────────
create table memberships (
user_id uuid not null references auth.users(id),
company_id text not null references companies(id),
role text not null check (role in ('respondent','approver')),
primary key (user_id, company_id)
);
alter table responses enable row level security;
alter table submissions enable row level security;
-- A member sees exactly one company's answers. Enforced by the database, not
-- by the application — so an export script or a stray query cannot leak.
create policy member_reads_own on responses for select
using (company_id in (select company_id from memberships
where user_id = auth.uid()));
-- ...and can only write while their cycle is open.
create policy member_writes_open_cycle on responses for insert
with check (
company_id in (select company_id from memberships where user_id = auth.uid())
and cycle_year in (select year from cycles where status = 'open')
);
create policy member_updates_open_cycle on responses for update
using (
company_id in (select company_id from memberships where user_id = auth.uid())
and cycle_year in (select year from cycles where status = 'open')
and not exists (select 1 from submissions s
where s.company_id = responses.company_id
and s.cycle_year = responses.cycle_year
and s.state = 'submitted')
);
-- Programme staff read everything; they are a separate role, not a company.
create policy staff_reads_all on responses for select
using (auth.jwt() ->> 'role' = 'programme_staff');A response row points at a question id. Rewrite the prompt, reorder the form, retire the question — the row is untouched and still joins.
question_versions rows for a closed cycle are never updated. What a member was actually asked in 2024 is recoverable exactly.
RLS means one member cannot read another's answers even if the application has a bug. That matters more than usual with commercially sensitive supply-chain data.
Six things that can happen to a question, and what the portal does about each
These are the branches implemented in the prototype. Getting them wrong is how a pre-filled survey quietly corrupts a dataset — a carried answer to a question that no longer means the same thing is worse than a blank.
Pre-fill silently. The member scrolls past it.
Pre-fill, flag, ask for a one-click confirmation. The answer is probably still right; the member decides.
Do not pre-fill for the members who chose it. Show them what they said last year and make them pick again. Everyone else is unaffected.
Never convert. A yes/no cannot become a year. Keep the old answer visible as context and take a fresh one.
Follow the lineage edge, not the ID. Show every parent answer beside the new field.
Blank, marked new, so it reads as an addition rather than something they forgot.
Four weeks of build, then the October cycle
Ordered so the riskiest thing — the migration of your existing years — happens first, while there is still time to find out that a historical column is ambiguous.
Model and migration
- Assign a persistent ID to every question in the current 90-question form and to every question in the historical exports
- Load the SurveyMonkey years into responses, keyed on those IDs
- Agree the lineage edges for anything that split, merged or was reworded between the existing years
- Reconcile: every historical answer lands, or is explicitly listed as dropped
Respondent portal
- Magic-link sign-in per company, RLS enforced
- The pre-filled questionnaire with the six carry states
- Save-as-you-go, review screen, submit and reopen
Programme admin
- Member dashboard, status, reminders
- Cycle editor with the impact preview before publish
- Long and wide exports, lineage view
Hardening and dry run
- Three real member companies run their return end to end
- Accessibility and mobile pass, email deliverability, audit log
- Load the 2026 questions and open the cycle in staging
October 2026 cycle opens
- Invitations to all 58 members
- Support cover through the first fortnight, which is when the questions arrive
- Handover: how to author next year's cycle without a developer
What it costs to keep on, per year
Indicative bands rather than a quote — vendor list prices move, and these get confirmed against current pricing before anything is signed. The shape is the useful part: this is a cheap system to own, because the workload is 58 companies logging in once a year.
Free tier covers this volume; the paid tier buys daily backups and no idle pausing, which is worth having for a system of record.
58 companies logging in a few times a year is nowhere near any paid threshold; a business plan is about support and custom domains, not capacity.
A few hundred emails a year sits inside most free tiers.
Or a subdomain of one you already own, in which case nothing.
The annual cycle change is the only recurring work, and the cycle editor is built so an administrator does it without a developer. Budget for the questionnaire rewrite being discussed, not built.
The point of the low-cost column in your brief is served better by owning a small application than by renting a per-seat platform: your cost does not move when the membership grows from 58 to 120.
What this prototype is, and is not
- The versioning model and all six carry rules
- Three cycles of question versions with genuine rewrites, a split, a merge, a retirement and a retired option
- Conditional logic — tier-gated questions and follow-ups that appear on a Yes
- Excel (.xlsx) exports written properly, plus CSV and print-to-PDF, scoped by company, question or year
- 58 companies with two cycles of filed history — 9,096 answers
- The impact preview, computed live against that history
- CSV exports in both shapes
- Sign-in — pick any company rather than a magic link
- Storage is your browser, not Postgres, so the demo needs no backend
- Emails, reminders, PDF copies
- The question content is a plausible textile CTP set, not yours — swapping in your 90 is a data task, not a build task
Everything in the stubbed column is ordinary work with known answers. Everything in the real column is the part that would have been argued about for three weeks in a kickoff, and it is already decided and clickable.