Loomstate
Technical note

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.

The short answer

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.

Stack options

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.

The model

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');
Answers never move

A response row points at a question id. Rewrite the prompt, reorder the form, retire the question — the row is untouched and still joins.

Old cycles are immutable

question_versions rows for a closed cycle are never updated. What a member was actually asked in 2024 is recoverable exactly.

Isolation is in the database

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.

The rules

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.

Nothing changed

Pre-fill silently. The member scrolls past it.

Wording changed

Pre-fill, flag, ask for a one-click confirmation. The answer is probably still right; the member decides.

An option was removed

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.

Answer format changed

Never convert. A yes/no cannot become a year. Keep the old answer visible as context and take a fresh one.

Split or merged

Follow the lineage edge, not the ID. Show every parent answer beside the new field.

Genuinely new

Blank, marked new, so it reads as an addition rather than something they forgot.

Delivery

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.

Week 1

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
Week 2

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
Week 3

Programme admin

  • Member dashboard, status, reminders
  • Cycle editor with the impact preview before publish
  • Long and wide exports, lineage view
Week 4

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
Launch

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
Running costs

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.

Supabase (database, auth, storage)
$0–25 / mo

Free tier covers this volume; the paid tier buys daily backups and no idle pausing, which is worth having for a system of record.

Hosting (Vercel or similar)
$0–20 / mo

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.

Transactional email (invitations, magic links, reminders)
$0–20 / mo

A few hundred emails a year sits inside most free tiers.

Domain
~$15 / yr

Or a subdomain of one you already own, in which case nothing.

Maintenance
scoped separately

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

Real
  • 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
Stubbed
  • 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.