saascode

Formlet: form and survey software you own, without a submission ceiling

builds · aug 09, 2026 · 8 min read · product: Formlet

A workshop coordinator at a registration desk watching an attendee's answers arrive in Formlet's responses inbox as he scans the table card's QR code with his phone

The status code was committed before the page was allowed to refuse.

Both respondent routes call notFound() when no published form resolves. Both answered 200 — for an unpublished form, and for a slug matching nothing at all — because each route's loading state flushes the shell, committing the status, before the page component runs. Nothing in the source looks wrong. Every check stays green. The status code is the only observable, and it was found by moving the loading file aside rather than by reading the source.

The stakes: on a form product, the hosted link is the product. A dead link answering 200 is indistinguishable from a working form to every automated caller, every uptime probe, and every crawler indexing the slug. The fix moved the availability check above the Suspense boundary via a shared cached read, keeping the route to one database round trip. Closed and re-verified: a draft slug returns 404, a nonexistent slug returns 404, a published form returns 200.

Starting point

Formlet is a multi-tenant form and survey builder. Teams drag fields onto a canvas, wire conditional logic on top — jumps and show/hide — and publish the same definition as either a conversational one-question-at-a-time flow or a classic single page. Responses are read, filtered and exported. The scope line is hard: this product collects data. No scoring engine, no result buckets, no per-session variables. That boundary is written into the type layer at every surface where drift could have occurred, and stated plainly in the shipped knowledge pack the buyer receives.

Four participant kinds, three of which are accounts: instance operator, workspace owner, collaborator, and respondent — who is not a role. Anonymous, reached by link, embed, or QR code. Every architectural decision below follows from that last distinction.

Three commitments preceded any code: submission via a service-role route calling one atomic database function, with no anonymous insert policies anywhere; one increment path for the response counter; responses pointing at an immutable versioned snapshot of the form they were shown. Adding a field tomorrow does not change what yesterday's respondents were asked.

Zero fund custody, an invariant: in-form payment lands directly in the workspace's own account. Raw secret-key storage is prohibited — that would violate the payment provider's terms.

Reading the market

One early assumption — that the product could win on generosity at the free tier — didn't survive scrutiny. The argument that does survive is ownership: the buyer runs the instance, sets the plans, and keeps the respondent data on infrastructure they control. A different transaction than a subscription.

The gates are on features and storage, not on response counts. The usage panel omits the responses row deliberately; the storage bar only renders when it has an honest numerator. The seats row ships because it has both figures.

Plan ladder as seeded and verified: Free at $0, Pro at $25, Team at $79. In-form commission — Formlet's own design — is 5% on the free tier, 0% on paid tiers.

The payment feature splits into two planes that are never allowed to touch: the workspace paying for Formlet, and a respondent paying the workspace. The legal counterweight was recorded at decision time: operating as a payment platform may carry identity and anti-money-laundering obligations in some jurisdictions. Named as an open operator item, not a closed one.

The decisions that shaped it

One writer for the response counter — and the writer refunds itself

The submit function folds quota-check, counter increment, and row insert into one statement-level gate inside one transaction — row-locking and testing the quota in the same statement that increments it. A read-then-write pattern lets every concurrent submission through. The increment happens before the insert is confirmed: when a duplicate token triggers an ON CONFLICT race, the counter is decremented in the same transaction and the original response id is replayed, so a duplicate never permanently consumes a quota slot.

The single-writer rule holds on delete. Deleting a response removes the data; it does not return the quota slot. A second writer on the counter would undermine the single-writer property that makes the quota race-free.

The aggregation trigger is allowed to fail, on purpose

The per-question rollup trigger touches only the rollup table — never the response counter. It swallows every error it raises: this is an after-insert trigger on the respondent write path, and an unhandled error would roll back a real submission to protect a derived analytics row. Losing a rollup degrades a chart; losing the response loses the respondent's data.

The public read path is a hand-built object, not a SELECT

The one public read function returns the published form plus a settings object rebuilt from an explicit allowlist of five keys, rather than passing the column through. The settings column also holds notification recipient emails and payment configuration — a pass-through would publish an organisation's internal configuration on every form load.

Verified over HTTP: anonymous reads of every table returned 401; anonymous calls to both project functions returned 404 — the API layer hides functions a role cannot execute; service-role returned 200.

Every address the product hands out comes from the browser

The share link, the embed snippet, and the QR code are composed from window.location.origin and from nothing else. A wrong link can be re-copied; a wrong QR is already on the table card. A server route for this was deliberately not built — it would have to invent a host, which is the failure the rule prevents.

The player runs two languages on one screen, and the boundary is fixed

Player chrome — progress indicators, closed notices, thank-you text — resolves against the form's locale, a setting the workspace chose. Form content — questions, option labels, descriptions — never passes through the translation system: it is authored once, in one language, by the organisation. Resolving it through the viewer's browser preference would render an English form's interface copy in whatever language the last logged-in visitor happened to choose on that device.

Cold-start handling bakes English fallbacks equal to seeded values — the first paint happens before any dictionary fetch resolves.

The embed widget is built by a step the application build does not run

Two build scripts, separate by default — the deployment configuration joins them. The widget build produces public/embed.js. When it is missing, every embedded form fails to load while the direct links keep working; that asymmetry is the signature of a missing widget file, which is why it is gated at release.

The embedded player is deliberately outside the front door

Both respondent routes are held outside request middleware on purpose, because absent from the middleware matcher has two possible meanings that look identical on disk: public on purpose, and a protected route somebody forgot to classify. The embed route's iframe-permission header is scoped to that route alone — the older header mechanism has no per-path vocabulary and cannot be overridden once emitted globally, only in the direction that breaks the widget.

What fought back

The status code was committed before the page was allowed to refuse

The full story is in the opening. Resolution: the availability check moved above the Suspense boundary via a shared cached read, keeping the route to one round trip. Closed forms return 200 and render a closed screen. Re-verified: draft slug returns 404, nonexistent slug returns 404, published form returns 200.

A missing environment variable was a working password

Three scheduled-job routes compared the incoming authorisation header against Bearer ${CRON_SECRET}. With the secret unset, Bearer undefined was the password. With it set but empty — which is what the shipped example environment file contained — it was Bearer (empty string). Those routes sit outside the protected middleware prefix, need no session, and each opens a service-role database client immediately after the comparison. The header check was the only boundary.

Measured against the running server: missing header returned 401, wrong secret returned 401, Bearer undefined returned 200. The documentation compounded it: the deploy guide stated that omitting the secret meant scheduled tasks would never run — false in the dangerous direction.

Fixed, not waived. One shared guard denies before any comparison when the secret is absent, empty, or whitespace — an unset secret is a 500, not an open door.

The interface drawings named database tables that do not exist

The machine-readable action contract was generated from the interface designs' own markers and listed routes that did not exist. Most named real resources under guessed paths; two named genuinely missing features that were then built; several had no schema behind them at all. Five named tables that do not exist — field definitions and conditional logic both live in JSONB. The names came verbatim from the design files. Every route was repointed with a recorded reason. Nothing dropped silently.

A duplication that was deliberately not removed

Two hooks read the same endpoint and were left unmerged — a decision, not an oversight. They differ on whether they trigger a live payment round trip, whether failure is shown or silent, and what privilege the caller needs. Any merge changes behaviour on one surface or the other. The rationale is in the header of both files.

One feature, three sources of truth, and no implementation

The seeded plan rows grant custom domains to the middle tier; the pricing and comparison designs say top tier only. It was not resolved by guessing — the pricing page renders whatever the database says, and the affected strings were written tier-agnostic. There is no domain column in the schema and no route. The feature ships disabled with the reason stated.

An entitlement the architecture cannot enforce where it applies

Branding removal is a paid entitlement, and on the public player it is not enforced server-side. The hide flag is an ordinary form setting, so a free workspace can flip it. The reason is the same decision that makes the player safe: the respondent has no session, and the public form payload carries no organisation identifier, so nothing on the client can evaluate an organisation's plan at that surface. Named as a real gap with the fix located — it belongs in the form update route — not closed.

Other limits carried openly

Nine controls ship visibly disabled with their reason stated — backing column or endpoint absent. Seven surfaces render an em-dash for "not measured" — never a fabricated zero — for cross-workspace payment counts and per-form analytics that each need a named backend addition.

The in-form payment lane is wired but unconfigured: fee arithmetic verified across hundreds of cases, zero-decimal currencies handled, but no call made against a live provider account. Whether the provider accepts the fee structure on a real direct charge is unknown.

What shipped

A multi-tenant form builder with drag-and-drop field editing across ten field types including file upload and payment, conditional logic on a shared evaluator used by both player modes and by server-side re-validation, two presentation modes from one form definition, per-question analytics via a trigger-maintained rollup, and response export as a stream. Link, embed, and QR sharing. Form closing by date or quota, enforced by the single-writer counter that holds under concurrency. Optional in-form payment landing directly in the workspace's own account.

Multi-workspace with row-level isolation, four roles, and cross-workspace operator visibility. Tenant isolation verified in both directions.

Release state: zero type errors, zero lint errors, translation coverage complete, 105 migrations applied and reconciled, 191 static pages, widget file present and byte-stable.

The buyer runs the instance. Every address the product hands out is derived from the host it is actually running on. The database, the plan definitions, and the respondent data are theirs.

See it

See Formlet →

end