Skip to content
Build Blueprint · free

Build your own forms (a Typeform replacement)

The handful of forms your team actually uses, one question per screen if you like, with responses in your own Postgres, an email or Slack alert and CSV export. Built with Lovable or Claude Code on Supabase.

Build time
8–10 hours
Running cost
$0–25 a month
Stack
Supabase (database + auth + edge functions), Lovable or Claude Code, Netlify or Vercel, Resend or Slack (alerts)
Verdict
BUILD See the math

What you’re building

A version scoped to what most teams actually use in Typeform:

  • Your specific forms, such as a lead form, a job application and a feedback survey, each on its own page like yourdomain.com/f/demo-request.
  • One question per screen with a progress bar, Enter to continue and a Back button, or all questions on one page for short forms. You choose per form.
  • Conditional logic for the paths you already know, such as “only ask about budget if the company has more than 50 people”.
  • Responses in your own Postgres table that only your team can read.
  • An email or Slack alert for each new response, plus an admin page to read responses, mark them handled and export CSV.
  • Spam protection: a hidden honeypot field, a minimum fill time and a rate limit per visitor.

Each form is a row in a forms table, with its questions stored as JSON. A new form is a new row, not new code, but someone still has to write that JSON (or ask Lovable to). That’s the honest trade-off against Typeform’s builder.

Not in version 1: a drag-and-drop form builder, drop-off analytics, file uploads, payments, video questions and ready-made integrations. Add them later only if you miss them.

Stack

  • Supabase for Postgres, magic-link sign-in for your team, row-level security and one edge function that receives responses. The free plan is enough to start. Free projects pause after about a week of low activity, and a paused project can’t take responses, so put quiet forms in a project you already pay for, or move to Pro ($25/month) once the forms matter.
  • Lovable or Claude Code to write the app (React + Tailwind is fine).
  • Resend or Postmark for the alert email (their free tiers cover low volume), or a Slack incoming webhook, or both.
  • Netlify or Vercel to host it on forms.yourdomain.com or under your main site.

Data model

Run this in the Supabase SQL editor. It creates the tables and locks everything down with row-level security (RLS) before any data goes in. Visitors can read open forms, but nobody can write responses from the browser. Only the edge function in prompt 3 does that.

-- The app talks to Supabase over REST, not GraphQL. Turning GraphQL off closes
-- an endpoint you don't use and clears two Security Advisor warnings.
drop extension if exists pg_graphql;

-- People on your team who can read responses
create table public.admins (
  user_id uuid primary key references auth.users on delete cascade,
  created_at timestamptz not null default now()
);

-- One row per form. Questions live in JSON, so a new form needs no new table.
create table public.forms (
  id uuid primary key default gen_random_uuid(),
  slug text not null unique check (slug ~ '^[a-z0-9-]{2,60}$'),
  title text not null check (char_length(title) between 2 and 120),
  intro text,
  questions jsonb not null default '[]' check (jsonb_typeof(questions) = 'array'),
  one_per_screen boolean not null default true,
  thank_you text not null default 'Thanks. We have your answers.',
  is_open boolean not null default true,
  created_at timestamptz not null default now()
);

-- One row per submission. Only the submit-response edge function inserts here.
create table public.responses (
  id uuid primary key default gen_random_uuid(),
  form_id uuid not null references public.forms on delete restrict,
  answers jsonb not null check (jsonb_typeof(answers) = 'object' and octet_length(answers::text) <= 20000),
  status text not null default 'new' check (status in ('new', 'handled', 'spam')),
  ip_hash text,
  created_at timestamptz not null default now()
);
create index responses_form_created on public.responses (form_id, created_at desc);
create index responses_ip_recent on public.responses (ip_hash, created_at desc);

-- Privileged helpers live in a schema the API can't reach.
create schema if not exists private;

create function private.is_admin() returns boolean
language sql stable security definer set search_path = public as $$
  select exists (select 1 from public.admins where user_id = (select auth.uid()))
$$;
revoke execute on function private.is_admin() from public;
grant execute on function private.is_admin() to anon, authenticated;

-- Row-level security
alter table public.admins enable row level security;
alter table public.forms enable row level security;
alter table public.responses enable row level security;

create policy "Admins see the admin list" on public.admins for select to authenticated
  using ((select private.is_admin()));

create policy "Open forms are public, admins see all" on public.forms for select to anon, authenticated
  using (is_open or (select private.is_admin()));
create policy "Admins add forms" on public.forms for insert to authenticated
  with check ((select private.is_admin()));
create policy "Admins edit forms" on public.forms for update to authenticated
  using ((select private.is_admin())) with check ((select private.is_admin()));

-- No insert policy on purpose: the browser can't write responses at all.
create policy "Admins read responses" on public.responses for select to authenticated
  using ((select private.is_admin()));
create policy "Admins update response status" on public.responses for update to authenticated
  using ((select private.is_admin())) with check ((select private.is_admin()));
create policy "Admins delete responses" on public.responses for delete to authenticated
  using ((select private.is_admin()));

-- Table access. New Supabase projects don't grant this automatically,
-- and RLS still decides which rows each role can see.
revoke all on public.admins, public.forms, public.responses from anon, authenticated;
grant select on public.forms to anon, authenticated;
grant insert, update on public.forms to authenticated;
grant select on public.admins to authenticated;
grant select, delete on public.responses to authenticated;
grant update (status) on public.responses to authenticated;
grant select, insert, update, delete on public.admins, public.forms, public.responses to service_role;

Then add yourself as an admin after you first sign in: insert into public.admins (user_id) select id from auth.users where email = 'you@yourdomain.com';

Close a form (is_open = false) instead of deleting it. Its responses keep pointing at it.

Build it in 8 prompts

Paste these one at a time into Lovable or Claude Code. Check each step works before moving on; it saves credits.

1. Create a React + Tailwind app connected to my Supabase project (project URL and publishable key from Settings → API Keys). The database already exists: admins, forms and responses, with RLS. Add magic-link sign-in at /admin. After sign-in, select the user's own row from admins; if there isn't one, show "You don't have access" and a sign-out button. Never use a secret or service role key in the browser.
2. Build the public form page at /f/:slug. Load the row from forms by slug and render its questions JSON: an array of objects like { "id": "company_size", "type": "choice", "label": "How big is your company?", "help": "", "required": true, "options": ["1-10", "11-50", "51-200", "200+"], "max": 500, "show_if": { "id": "role", "in": ["Founder"] } }. Support the types short_text, long_text, email, phone, number, choice, multi_choice, yes_no and rating (1 to 5). Hide a question when its show_if doesn't match the answers so far. If one_per_screen is true, show one question at a time with a progress bar, Enter to continue, a Back button and autofocus; otherwise show every question on one page. Validate in the browser (required, email format, max length, allowed options) and show "This form is closed" when the slug isn't found.
3. Create a Supabase Edge Function called submit-response. Visitors call it without signing in, so deploy it with verify_jwt = false and do every check in code. Accept POST JSON { form, answers, website, startedAt }. Reject bodies over 25 KB. If website (a honeypot) is filled in, or less than 3 seconds passed since startedAt, return 200 { ok: true } without saving. Load the form by slug with the project's secret key from the function's environment (SUPABASE_SECRET_KEYS, or SUPABASE_SERVICE_ROLE_KEY on older projects) and return 404 if it's missing or closed. Validate answers against its questions with the same rules as the page: only known ids, required unless hidden by show_if, types, options and lengths. Hash the caller's IP (first value of x-forwarded-for) with SHA-256 and a secret IP_SALT, and return 429 if that hash already has 5 responses in the last 10 minutes. Then insert { form_id, answers, ip_hash } into responses. Never send database errors back to the browser, and only allow CORS from my site's domain.
4. Connect the form page to submit-response. Add a hidden honeypot input named website (positioned off-screen, tabindex -1, autocomplete off, aria-hidden), record startedAt when the form first renders, and on submit call supabase.functions.invoke('submit-response', ...). Show the form's thank_you text on success. On 429, say "You've sent this form a few times already. Please try again in 10 minutes." On any other error, keep the answers on screen and offer a retry.
5. In submit-response, send an alert after a successful insert, but never let a failed alert fail the submission. If SLACK_WEBHOOK_URL is set, post the form title, the first four answers and a link to /admin/responses/<id>. If EMAIL_API_KEY and ALERT_EMAIL_TO are set, send a plain-text email with the same content through Resend's API from ALERT_EMAIL_FROM, and set Reply-To to the respondent's answer to an email question if the form has one. Log alert failures with console.error so they show in the function logs.
6. Build /admin/responses for admins. List forms with their number of new responses. For one form, show its responses newest first, with filters for status and date range and a search box, plus a detail panel that shows each question's label next to the answer and puts answers to questions that no longer exist under "Older questions". Admins can mark a response handled or spam (update status only) and delete a response after a confirm step. Render every answer as plain text, never as HTML.
7. Add CSV export to /admin/responses. For the selected form and date range, write one row per response with the columns submitted_at, status and then one column per question in the form's current order (header = question label), joining multi_choice answers with "; ". Escape quotes and line breaks properly, and prefix any cell that starts with =, +, - or @ with a single quote so spreadsheets don't run it as a formula. Name the file <form-slug>-<yyyy-mm-dd>.csv.
8. Add /admin/forms. List forms with an open/closed switch (updates is_open), a link to each form and a copyable embed snippet (an iframe of /f/<slug>?embed=1 that hides the header and footer). Add a questions editor that shows the JSON in a code editor with validation (unique ids, known types, options for choice questions), a live preview, and a warning before saving if a question id that already has answers was removed or renamed. Finish with page titles, meta descriptions, empty states and a check of every page on a phone.

Security checklist

  • Supabase Security Advisor shows no warnings, and RLS is on for every table.
  • Only the publishable (anon) key is in the browser. No secret or service role key is in the browser code or the repo; the edge function reads it from its own environment.
  • Signed-out visitors can read open forms but can’t read, add, change or delete responses (try it from the browser console with the Supabase client).
  • A signed-in user who isn’t in admins sees no responses, and an admin can change only a response’s status.
  • A submission isn’t saved when the honeypot is filled in, when it arrives within 3 seconds, or when it’s the sixth from one address in 10 minutes.
  • Answers render as plain text in the admin, and the CSV export escapes cells that start with =, +, - or @.
  • Allow new users to sign up is off in Supabase Auth once your team has accounts.
  • Your privacy notice says what each form collects and how long you keep it, and you can delete one person’s responses when they ask.

Move off Typeform

  1. List the forms you actually use (most teams find two to five) and recreate each as a row in forms. Keep the question wording, and give each question a short id like email or company_size. Retyping is usually quickest, or pull the form definitions with Typeform’s API.
  2. Download past responses: in Typeform, open each form’s Results, then Responses, and export them as CSV. There’s no single export for every form, so do it form by form, or use Typeform’s API. Yes/No answers come out as 1 and 0.
  3. Most teams keep those CSV files as an archive and start fresh. If you want old responses in the app, ask for a one-off import page that maps the CSV columns to your question ids.
  4. Swap the links and embeds on your site to the new forms and keep the Typeform versions open but unlinked for a week. Then close them.
  5. Cancel Typeform after the last renewal you’ve paid for, and only once your exports are saved.

After launch

  • About 30 minutes a month: look at what landed as spam, check the Supabase advisor, update dependencies.
  • If spam still gets through, add a captcha such as Cloudflare Turnstile and check its token in submit-response before anything is saved.
  • Decide how long you keep responses, and delete older ones every month (by hand, or with a scheduled pg_cron job).
  • Watch for teammates asking for a new form every week. If non-technical people need to build forms that often, that flexibility is what Typeform is worth paying for.

Before you build

Check the numbers for your own team first. Stack Scan reads a card statement in your browser and shows whether building beats what you actually pay.

Get new blueprints by email

One email when a new blueprint is out. Tell us which tool you want next.