Self-hosted, branded forms with your own database. Built as a replacement for Typeform when you want the responses to live somewhere you control.
- Form builder — short and long text, email, multiple choice, checkboxes, dropdowns, yes/no, file upload, and section breaks
- Branding per form — colours, logo, header image, fonts, button style, corner radius, with a live preview
- Rich text — bold, headings and lists in your intro and question text, edited visually
- Workspaces — group forms and give each group its own password, so people only see their own forms
- Responses — dashboard and CSV export, each response labelled by the person who filled it in
- Notifications — get a message the moment someone submits. Point it at a Slack webhook and it posts to your channel; point it at Zapier, Make or n8n and route submissions anywhere. One webhook per workspace covers every form in it, and past responses can be re-sent
- Files — uploads land in your own storage
MIT licensed. Made by Builders Studio.
- Click the button. Vercel copies the repo and asks for two values:
ADMIN_PASSWORD— the password you'll log in withENCRYPTION_KEY— any long random string (openssl rand -base64 32)
- When the first deploy finishes, open the project on Vercel → Storage → Create Database → Neon (Postgres) and connect it to the project. Leave the "Custom Prefix" field empty.
- Optional, only if your forms accept file uploads: Storage → Create → Blob, connected to the same project.
- Redeploy (Deployments → ⋯ → Redeploy). The tables are created for you during the deploy.
Log in with your ADMIN_PASSWORD and create your first form.
- A Vercel account (the free plan is enough), or any Node host
- A Postgres database — Neon's free plan works, and Vercel sets it up for you
- Optionally a Blob store for file uploads, and a Slack/Zapier webhook for notifications
- Nothing else. No terminal needed for the steps above.
To develop locally you also need Node 24.
npm install
cp .env.example .env # then set ADMIN_PASSWORD and ENCRYPTION_KEY
npm run db:dev & # throwaway local Postgres, nothing to install
npm run db:push # create the tables
npm run devThe last command prints the URL to open:
Builders Forms -> http://localhost:31847
How you know it worked: that URL loads a page with a single password box. Enter
your ADMIN_PASSWORD and you land on an empty forms list with a New form button.
That is a working install — no forms yet, which is correct on a fresh database.
The port is picked at random above 20000 on first run and remembered in .dev-port,
so the URL stays the same next time. Port 3000 is shared by almost every local
project, and browsers send localhost cookies across all ports — enough of them and
every request fails with a 431. Staying off 3000 avoids the whole class of problem.
Want a specific port? PORT=3000 npm run dev.
| What you see | What it means |
|---|---|
ECONNREFUSED ... 5434 |
The database isn't running. Start it with npm run db:dev. |
431 Request Header Fields Too Large |
Cookie overflow from other localhost projects. Already handled by .npmrc; if it persists, clear localhost cookies. |
ENCRYPTION_KEY is not set |
Add it to .env. Set it once and never change it — rotating it makes stored workspace passwords unreadable. |
| Login says "Wrong password" | .env isn't loaded. Confirm the file is named .env and restart npm run dev. |
Any Postgres works — the app only ever needs a DATABASE_URL. It connects with
drizzle-orm over node-postgres, so there is no provider SDK to swap.
| Provider | Notes |
|---|---|
| Neon | Recommended. Plain serverless Postgres, no public API surface. vercel integration add neon sets DATABASE_URL for you. |
| Supabase | Fully supported. See the lockdown note below. vercel integration add supabase. |
| Anything else | Aurora, RDS, Railway, or your own box. Paste the connection string. |
| Local | npm run db:dev — embedded Postgres 18 (PGlite), nothing to install. |
Supabase serves a public Data API at https://<project>.supabase.co/rest/v1 using
the anon key, which is meant to be public. Tables in the public schema with no
row-level security are readable and writable through it, which would walk
straight past this app's login.
This app never uses that API — every query runs server-side over DATABASE_URL.
So drizzle/harden.sql shuts the door:
- Revokes every
anon/authenticatedprivilege on tables, sequences, routines and the schema itself. - Turns off OpenAPI discovery, so
/rest/v1/stops listing your tables. - Sets default privileges, so a future table can't quietly re-open the hole.
- Enables row-level security on all seven tables with no permissive policies — deny by default for anyone who is not the table owner.
It runs automatically as part of npm run build, is idempotent, and detects the
provider: on Neon or plain Postgres the anon role doesn't exist, so steps 1–3
are skipped and only RLS is applied. The same file is correct everywhere.
Adapted from
vsi-conversation-core'ssecurity_lockdown.sql, which solved this for the same reason: all access through one trusted role, PostgREST unused.
Workspace isolation is enforced in application code, not by RLS policies. The
browser never touches the database; every query runs in a server action behind a
requireFormAccess / requireFolderAccess / requireMaster check. RLS here is a
backstop against the Data API, not the primary control. tests/isolation.test.ts
pins the behaviour so a forgotten check fails CI.
There is one login page, and the password decides what you see.
| Password | Sees |
|---|---|
ADMIN_PASSWORD |
Everything: all workspaces, forms and responses |
| A team password | The same as above. Create these under Team |
| A workspace password | Only that workspace's forms. Create these under Workspaces |
Workspace and team passwords are generated for you, encrypted in the database, and can be revoked at any time — the person is logged out immediately.
Nobody sees a workspace they don't have the password for, or even learns that it exists.
Every form has one unguessable link, created with the form. Publish the form, copy the link from the forms list, and send it. Duplicate a form to make a tweaked version for a different customer; duplicates group into a folder.
Set one webhook per workspace (Notifications, next to New form). Every submission in that workspace posts to it with the form name, who it was for, and the answers. Slack incoming webhooks work out of the box, as do Zapier, Make and n8n. Past responses can be re-sent from the Responses tab.
- Login locks out for 15 minutes after 8 wrong attempts
- Passwords are encrypted at rest, and compared in constant time
- Sessions are tied to the password, so revoking one ends the session
- Admin pages are excluded from search engines
- Webhook URLs are checked so they can't be pointed at internal addresses
- Admin endpoints reject cross-site requests
- Submissions are rate limited
There is no two-factor authentication — a password alone is enough to log in.
Treat ADMIN_PASSWORD accordingly.
Next.js, Tailwind CSS, Drizzle ORM, Postgres. Deploys to Vercel; any Node host with a Postgres database works too.