Open Form

Postgres

Keep responses in Postgres instead of a SQLite file: Supabase, Neon, RDS, Railway or your own server. Set one connection string; Open Form creates its tables.

SQLite needs a persistent disk. When there isn't one (Vercel and other serverless hosts), or you want responses in a database you already run, point Open Form at Postgres with one environment variable:

.env
OPEN_FORM_DATABASE_URL='postgres://user:password@host:5432/dbname'

Percent-encode special characters in the user name and password, such as # as %23 and @ as %40. Open Form refuses a connection string it can't read rather than guess which part is the host.

open-form dev, start, list, responses export, delete, the Vercel function and the Next.js route all use it. Nothing else changes: Studio, filters, exports, webhooks and response numbers work the same as on SQLite.

Install the driver

Postgres support uses pg, which isn't installed until you need it.

npm install pg

open-form init --database postgres adds it for you.

Set the connection string

Put it in .env for local work (the .gitignore from open-form init already ignores it), and in your host's environment settings in production. The environment wins over .env.

Never write the connection string into open-form.config.ts: that file is committed. To reuse a variable your app already has, read it from the environment instead, and open-form check fails when the config holds a URL with a password.

open-form.config.ts
export default defineConfig({
  storage: { url: process.env.DATABASE_URL },
});

OPEN_FORM_DATABASE_URL wins over storage.url. Open Form doesn't read DATABASE_URL on its own, because in many apps it points at the app's own database. The one exception is the Vercel function: there, when nothing else picks a database, it uses the POSTGRES_URL or DATABASE_URL that a Vercel Marketplace database sets.

Check it

npx open-form db status
  ┃  Storage   Postgres (from OPEN_FORM_DATABASE_URL)
  ┃  Database  postgres://forms:***@db.example.com:5432/app?sslmode=verify-full
  ┃  Status    ✔ connected PostgreSQL 16.4 · schema open_form · migration 1
  ┃  Responses contact 12 · meetup 87

The first connection creates the tables. See open-form db status.

Where the tables go

Open Form keeps its tables in their own schema, open_form, away from your app's tables in public. It creates the schema on first start, then the same tables as SQLite: submissions, form_versions, form_counters, form_overrides, rate_limits, revoked_sessions and meta, plus submission_files, which is reserved for file uploads and unused today. Answers are stored as json, so they come back with their fields in the order the respondent's form sent them, and you can query them:

SELECT number, data->>'email' AS email, created_at
FROM open_form.submissions
WHERE form_id = 'meetup'
ORDER BY number DESC;

There's no separate migrate command: whatever opens the database applies any migration that's missing. That's the dev server, open-form start and the Next.js route on their first request, and db status, list, responses export and delete. When many open it at once (serverless cold starts), a Postgres advisory lock lets one run the migrations while the others wait; once the database is up to date, opening it takes no lock.

To give Open Form a role of its own, the role needs to create the open_form schema on first start, or own one you create for it:

CREATE ROLE open_form_app LOGIN PASSWORD '…';
CREATE SCHEMA open_form AUTHORIZATION open_form_app;

Encryption

Connections to any host other than localhost verify the server's certificate. A connection string without sslmode gets sslmode=verify-full, so answers are never sent in the clear by accident.

  • sslmode=verify-full: encrypt and verify. Write this one. Works with providers whose certificates come from a public authority, like Neon.
  • sslrootcert=./ca.crt: verify against your provider's own certificate authority, for providers that sign with one, like Supabase. pg reads the file relative to the directory the process runs in, not the project root, so use an absolute path if you start Open Form from another directory. On Vercel, ship the file with the function as shown in Vercel.
  • sslmode=no-verify: encrypt without checking who answers. Use it only on a network you trust, or for a test against a provider whose certificate can't be verified, like Railway's Postgres, which signs its own.
  • sslmode=disable: no encryption. Open Form logs a warning when the host isn't local.

sslmode=require also verifies in pg, unless the URL adds uselibpqcompat=true; verify-full means the same thing either way.

Serverless and pooling

Each server or function keeps a small pool of connections (pg's default of 10). On serverless hosts, many short-lived functions can run out of connections, so use your provider's pooled connection string: Neon's -pooler host, or Supabase's transaction pooler on port 6543. Open Form doesn't use named prepared statements, so transaction pooling works.

Without a writable disk, also set OPEN_FORM_SECRET. See Vercel.

Deleting responses

A deleted response is gone from every query at once, but Postgres keeps the old row on disk until VACUUM reclaims it (autovacuum does this on its own). Backups and point-in-time recovery keep copies for as long as your provider retains them. SQLite, by contrast, overwrites a deleted response immediately.

Moving from SQLite

Setting OPEN_FORM_DATABASE_URL starts with an empty Postgres database: responses already in .open-form/open-form.db stay in that file until you copy them. open-form db copy moves them across with their response numbers and Studio's settings:

npx open-form db copy --dry-run
npx open-form db copy

Run it once the connection string is set and before the form takes responses on Postgres: it copies only into an empty database, so a second run, or a Postgres that already has responses, writes nothing. On a deployed app, point your local OPEN_FORM_DATABASE_URL at the production database and run it from the workspace that holds the SQLite file.

The two databases stay separate afterwards. If you switch back to SQLite, Studio shows the responses in .open-form/open-form.db again, and the ones collected in Postgres are only visible while OPEN_FORM_DATABASE_URL points there. To read the SQLite file for one command without switching, leave the variable empty (this works as long as storage.url isn't set in open-form.config.ts):

OPEN_FORM_DATABASE_URL= npx open-form responses export meetup -o meetup-responses.json

What's next

On this page