2025 to 2026
FlexiForm
Dynamic form platform with a usage-metered billing engine
Role: Lead engineer. Owned the Next.js app end to end; majority author of the Rails API.
- lines of code
- ~40klines of code
- question types
- 7question types
- the whole budget
- 512MBthe whole budget
- commits
- 771commits
Context
What this is for
Teams that run recurring processes (onboarding checks, compliance reviews, site inspections) mostly run them in spreadsheets, because every form product assumes the form is the end of the story. FlexiForm treats the form as the input to a monitoring table that a reviewer works in afterwards.
Problem
The thing that was actually hard to get right
The form schema is owned by the customer and changes under live data, so answers cannot be columns. Reviewers edit the same cells repeatedly and the history has to survive. Every read has to be authorized against an organization four joins away. And the whole thing had to run inside a 512MB dyno alongside a Selenium scraping pipeline.
What I built
The surface area
- A question-by-question builder with seven server-defined types, per-question rank, required and editable flags, and a file limit, with drag-and-drop reordering persisted as rank.
- A virtualised, spreadsheet-style monitoring grid where a reviewer edits answers in place over an append-only history.
- A reviewer workflow with an explicit state machine: invited, submitted, reviewed, rejected, plus an orthogonal lock that can hand editing to the first reviewer only.
- A template marketplace where a seller's form can be forked into your own organization, tracked through a self-referential template graph.
- Stripe subscription tiers with a server-owned price catalogue, signature-verified webhooks, and live plan state pushed to the browser over ActionCable.
- A nightly scraping pipeline over ten named brokerage sites with fuzzy duplicate detection, change notifications and a weekly digest.
The hard parts
Decisions worth defending
Each of these names its trade-off, because a decision without one is a preference.
The latest answer, without losing the previous ones
Answers are append-only: editing a cell writes a new row. Reading a survey therefore means the latest row per question, which is a DISTINCT ON over (user_survey_id, question_id) ordered by created_at descending. The subtle part is that the association has to stay correct when Rails batch-preloads it across many surveys at once, so the ordering has to include the partition key in the right position, and it is backed by a dedicated index. A second, deliberately skinny association selects only ids, so JSON:API relationship linkage never drags answer text into memory.
The trade-off
DISTINCT ON is Postgres-specific and the query is harder to read than a current_answer column would be. The history is the product, so denormalising a pointer to the latest row would be a second source of truth to keep honest.
512MB as a design input, not a complaint
Four forked Rails processes do not fit in 512MB alongside a headless Chrome, so the job system runs in async mode: worker, dispatcher and scheduler are threads inside one process of around 170MB. Selenium is lazily required because loading it costs about 11MB resident, and the scrape job is concurrency-limited to one so two browsers never coexist. A stale-run reconciler force-finishes orphaned runs using a single UPDATE ... RETURNING as an atomic flip-and-enqueue guard, and the notification window is clamped so a late reconcile cannot re-mail a day of alerts. Spreadsheet exports are written to a tempfile and streamed rather than held in memory, where the buffer would cost roughly three times the file size inside a web thread.
The trade-off
One process means one failure domain: a worker crash takes the scheduler with it. At this scale and cost the restart is cheaper than the memory, and the reconciler exists because that trade was made knowingly.
Whose plan is it?
A quota engine looks obvious until you ask whose plan applies. A member on the free tier acting inside an organization owned by a paying customer must get the paying limits, not their own, or the product breaks the moment anyone invites a colleague. Limits are therefore resolved from the organization owner's plan, bulk operations pre-check current plus requested against the limit rather than failing partway, and a quota failure returns 403 with a distinct code from an authorization failure, so the client can offer an upgrade instead of showing a permission error.
The trade-off
The owner becomes a single point of billing failure: if their subscription lapses, every member drops at once. That is the behaviour the business wanted, and it is why the distinction between "not allowed" and "not paid for" had to be visible in the API.
Ownership
Who wrote what
Commit counts from the repository history, both of my git identities summed. I would rather give you this than a claim you have to take on trust.
| Surface | Commits | My share |
|---|---|---|
| Next.js app | 309 | 98% of commits, owned end to end |
| Rails API | 273 | 59% of commits, majority author |
Stack
What it runs on
Frontend
- Next.js 16
- React 19
- Redux Toolkit
- TanStack Virtual
- FormKit drag-and-drop
- TipTap
- Stripe
- ActionCable
Backend
- Rails 8.1
- Ruby 4.0
- PostgreSQL
- Solid Queue (on Postgres, no Redis)
- ActionCable (on Postgres)
- Devise + JWT + TOTP
- Pundit (18 policies)
- Selenium + Nokogiri
- Jaro-Winkler fuzzy matching
Infrastructure
- Heroku (512MB dyno)
- AWS S3
- Resend
- Stripe
- Headless Chrome
Testing & quality
- RSpec: the spec suite is larger than the application code (12.4k vs 10.1k lines)
Where it is now
Current status
In production with paying subscribers. The marketing domain and the API domain diverged during the build and have not been reconciled, which is a loose end rather than a bug.