Vibecode Youform
track this build4 phases, 12 steps, beginner friendly0%Youform's solo core is compact: build a conversational forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance.
You are building a lean indie version of Youform. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Youform · indie build A conversational form builder whose hard problems are changing a form without corrupting answers already collected, getting every submission onto disk before anything else is attempted, and accepting file uploads larger than a serverless request body. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus one worker process for the outbox | the worker is what makes 'store first' possible | | Storage | SQLite through better-sqlite3, one file, written synchronously on submit | the submit path should touch nothing that can time out | | Schema | Versioned JSON with stable question ids, frozen on publish | an edited question rewrites the meaning of old answers | | Uploads | Presigned direct-to-storage, never through the API route | a serverless request body is far smaller than a CV or a portfolio | | Delivery | An outbox table drained with backoff, each delivery carrying a stable id | the receiver needs something to deduplicate on | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the outbox worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **S3-compatible object storage with presigned uploads** · pennies per month at personal volume - Why: Uploads have to bypass your API route, and presigned direct upload is the documented way to do it. - Get it: Any S3-compatible provider, or MinIO locally for development. - Verify: A presigned PUT from a browser stores a file you can then read by key - [ ] **A transactional email provider key** · free tier at personal volume on most providers - Why: Notification email goes out from the outbox, and a provider gives you delivery reporting you would otherwise have to build. - Get it: Create an account, verify a sending domain, and issue a key limited to sending. - Verify: A test message arrives and the provider reports it accepted - [ ] **Your platform's request body limit, response cap and retention** · free - Why: Each of these is asserted by a test, and choosing them after seeing a failure means choosing whatever passes. - Get it: Read your hosting platform's current published request body limit, then write down your per-form response cap and how long uploads are kept. - Verify: All three are in the configuration and named in tests - [ ] **A webhook receiver you can break on purpose** · free - Why: The outbox is only testable against a receiver that times out, returns errors, and then recovers. - Get it: A tiny local server with switchable behaviour, or any request bin you control. - Verify: You can make it fail for an hour and then succeed ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for Youform, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Youform The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a one-question-at-a-time form that cannot lose a submission to replace Youform. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule. A response that reached your server is never lost. Everything else here can fail and be retried: the notification email, the webhook, the spreadsheet sync, the redirect to a thank-you page. The answer itself cannot. So the submission is written and committed before any of those is attempted, and every side effect is a row in an outbox that a worker drains. If the receiving endpoint is down for a day, the responses are still on disk and the webhooks go out when it comes back. The same rule applied to the schema: a published form version is immutable. Editing a question in place turns last week's answers into data whose question nobody can reconstruct, and an export that mixes them is worse than no export. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus one worker process for the outbox · the worker is what makes 'store first' possible - Storage: SQLite through better-sqlite3, one file, written synchronously on submit · the submit path should touch nothing that can time out - Schema: Versioned JSON with stable question ids, frozen on publish · an edited question rewrites the meaning of old answers - Uploads: Presigned direct-to-storage, never through the API route · a serverless request body is far smaller than a CV or a portfolio - Delivery: An outbox table drained with backoff, each delivery carrying a stable id · the receiver needs something to deduplicate on ### Data model (create this before Phase 1) - forms(id, title, slug, current_version_id, created_at, closes_at, response_cap) - form_versions(id, form_id, ordinal, schema_json, published_at) · published rows are never updated, only superseded - schema_json holds questions(id, type, prompt, required, validation_json, logic_json) · ids stay stable across versions so answers can be compared - responses(id, form_id, form_version_id, started_at, submitted_at, resume_token_hash, state) - answers(response_id, question_id, value_json) · stored against the version that asked the question - uploads(id, response_id, question_id, storage_key, filename, bytes, sniffed_type, sha256) - outbox(id, response_id, kind, payload_json, delivery_id, attempts, next_attempt_at, delivered_at, last_error) - Answers key on question id plus version, so renaming a question never silently merges two different questions into one export column. ### Phase 1 · A schema you can edit without rewriting history Build: Store the form as JSON with stable question ids. Publishing writes an immutable version row; editing afterwards creates the next version. Each response points at the version it saw. Export reconciles versions into columns without merging two different questions. Trap: Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. Done when: A published version row is never updated, asserted by a test that attempts it. Editing a published form produces a new version and leaves existing responses pointing at the old one. An export of a form with two published versions keeps both versions' questions distinguishable rather than collapsing them. Question ids survive a prompt rewrite, so the same column follows the question. Do not build yet: No public fill page, no submission, no uploads. ### Phase 2 · Filling it in, and coming back tomorrow Build: Render one question at a time from the published schema, saving each answer as it is given against an unguessable resume token. Validate on the server from the same schema the page rendered. Check the logic graph at publish time for a branch that skips a required question. Trap: Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. Done when: A submission posted directly to the endpoint with an out-of-range value is rejected and names the offending question id, tested without a browser. A half-filled response resumes with the answers already given, and a stale token is rejected. Publishing a form whose logic skips a required question fails with the path that does it. Every validation rule the page enforces is enforced again on the server, verified by review. Do not build yet: No webhooks, notifications or uploads yet. ### Phase 3 · Store first, then everything else Build: Write the response and its answers in one transaction, commit, then return. Enqueue the webhook and the notification as outbox rows with a stable delivery id. A worker drains the outbox with exponential backoff, signs each webhook, and retries only on failure. Trap: Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. Done when: The submit path performs no network call, verified by a test that fails the run if one is attempted. A webhook receiver returning errors for an hour leaves every response intact and delivers when it recovers, tested with a fake receiver. Killing the worker mid-delivery redelivers with the same delivery id so the receiver can deduplicate. Backoff is bounded and a permanently failing row surfaces on a page rather than retrying forever in silence. Do not build yet: No uploads, export or rate limiting yet. ### Phase 4 · Uploads that never touch your API route Build: Issue a short-lived presigned upload URL scoped to one response and one question. The browser puts the file straight to storage and posts back the key. Enforce the size cap in the storage policy as well as the browser, and record the type by sniffing the stored bytes. Trap: Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. Done when: A file larger than the hosting platform's request body limit uploads and attaches to a response, tested with a file over that limit. The presigned URL is scoped to one response and one question and expires, tested by reusing it for another. The recorded type comes from sniffing the stored bytes, so a file renamed to look like a PDF is recorded as what it actually is. A file over the configured cap is refused by the storage policy, not only by the page. Do not build yet: No export, deletion or rate limiting yet. ### Phase 5 · Whose data it is, and who can knock Build: Export CSV and JSON including upload keys, and a bundle that contains the files themselves. Deleting a response deletes its stored files too. Close a form on a date or a response cap. Rate limit per address and per form, and put the responses view behind authentication. Trap: A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. Done when: An export round trips: the bundle re-imports into an empty instance with answers, versions and files intact, tested. Deleting a response removes its stored files, verified by fetching the key afterwards and getting nothing. A form at its response cap closes and shows a closed page rather than accepting. The responses view requires authentication and a direct request without it returns nothing, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments collected inside a form, which pulls card handling and refunds into a form builder. - A template gallery, which is content work rather than engineering and adds nothing to the loop. - Team accounts, approvals and shared workspaces, which multiply every screen by a permission model. - Running your own notification email at scale. Send through a provider you already trust and stop there; inbox placement is a separate project with its own operational burden. ### README must contain - That a published form version is immutable and each response records which version it answered - That validation is enforced on the server from the same schema the page rendered - That the submit path commits before any webhook or email is attempted, and side effects live in an outbox - That uploads go direct to storage because the hosting platform's request body limit is smaller than an ordinary attachment - That deleting a response also deletes its stored files, and that the export bundle re-imports ===== AGENTS.md ===== # Agent instructions · Youform indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, plus one worker process for the outbox, SQLite through better-sqlite3, one file, written synchronously on submit, Versioned JSON with stable question ids, frozen on publish, Presigned direct-to-storage, never through the API route, An outbox table drained with backoff, each delivery carrying a stable id. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ===== BUILD_PLAN.md ===== # Build plan · Youform A conversational form builder whose hard problems are changing a form without corrupting answers already collected, getting every submission onto disk before anything else is attempted, and accepting file uploads larger than a serverless request body. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A schema you can edit without rewriting history Publishing freezes a version and every response records which version it answered. ### Steps 1. Freeze a version on publish Immutability is enforced in the data layer, not by remembering. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Keep question ids stable across edits The id is the column; the prompt is just the label. Files: `src/lib/schema.ts` 3. Reconcile versions at export time Two versions of one question are two columns, not an average. Files: `src/lib/export.ts`, `tests/export-versions.test.ts` ### Done when - [ ] A published version cannot be updated - [ ] Editing creates the next version - [ ] Old responses still point at their version - [ ] Export keeps two versions' questions distinct ### Watch out - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. ## Phase 2 · Filling it in, and coming back tomorrow One question at a time, resumable, with validation that the browser cannot talk its way past. ### Steps 1. Validate server-side from the version's schema One rule set, applied twice, the second time authoritatively. Files: `src/lib/validate.ts`, `tests/server-validation.test.ts` 2. Save answers as they are given, behind a resume token A long form abandoned at question nine should not start again at one. Files: `src/app/api/answer/route.ts` 3. Walk the logic graph at publish Unreachable questions and skipped required ones are publish-time errors. Files: `src/lib/logic.ts`, `tests/logic-graph.test.ts` ### Done when - [ ] A direct post with an invalid value is rejected by question id - [ ] A partial response resumes - [ ] Logic that skips a required question fails at publish - [ ] Every client rule is re-applied on the server ### Watch out - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. ## Phase 3 · Store first, then everything else Submitting commits the response before any webhook or email is attempted. ### Steps 1. Commit the response, then return No network call may sit between the respondent and a success page. Files: `src/app/api/submit/route.ts`, `tests/no-network-on-submit.test.ts` 2. Enqueue side effects with a stable delivery id The id is what lets a receiver ignore the duplicate. Files: `src/lib/outbox.ts` 3. Drain with bounded backoff and a dead letter view A row failing forever should be visible, not invisible. Files: `worker/drain.ts`, `src/app/deliveries/page.tsx` ### Done when - [ ] The submit path makes no network call - [ ] A failing receiver never costs a response - [ ] Redelivery carries the same delivery id - [ ] Permanently failing rows are visible ### Watch out - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. ## Phase 4 · Uploads that never touch your API route A large file attaches to a response without passing through the request body. ### Steps 1. Presign per response and per question, briefly A reusable URL is an open upload endpoint. Files: `src/app/api/upload-url/route.ts`, `tests/presign-scope.test.ts` 2. Cap size in the storage policy A page attribute is a suggestion; the policy is the limit. Files: `src/lib/storage.ts` 3. Sniff the stored bytes for the type The filename is whatever the respondent typed. Files: `src/lib/sniff.ts`, `tests/sniff.test.ts` ### Done when - [ ] A file over the platform body limit attaches successfully - [ ] A presigned URL is single-purpose and expires - [ ] The recorded type comes from the bytes - [ ] The size cap is enforced by the storage policy ### Watch out - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ## Not in this build - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. ## After v1, if you want it - Partial response analytics showing where people abandon, computed from the answers you already store - A second delivery target such as a spreadsheet, added as an outbox kind and nothing else - Scheduled exports to storage you control, reusing the bundle format ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. The whole product. DATABASE_PATH=./data/forms.db # Required. Where presigned uploads go. Never through the API route. STORAGE_ENDPOINT=https://s3.eu-west-1.amazonaws.com # Required · secret. Used only to sign upload URLs and to delete on request. STORAGE_ACCESS_KEY=AKIA...redacted # Required · secret. Never reaches the browser. Presigned URLs do. STORAGE_SECRET_KEY=redacted # Required. Enforced in the storage policy as well as the page. UPLOAD_MAX_BYTES=26214400 # Required · secret. Notification email, sent from the outbox worker. EMAIL_API_KEY=re_...redacted # Required. After this a row is dead lettered and shown, not retried forever. OUTBOX_MAX_ATTEMPTS=12 # Required. Per address, per form. The thing that stops the overnight script. RATE_LIMIT_PER_HOUR=30
You are building a lean indie version of Youform. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Youform · indie build A conversational form builder whose hard problems are changing a form without corrupting answers already collected, getting every submission onto disk before anything else is attempted, and accepting file uploads larger than a serverless request body. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus one worker process for the outbox | the worker is what makes 'store first' possible | | Storage | SQLite through better-sqlite3, one file, written synchronously on submit | the submit path should touch nothing that can time out | | Schema | Versioned JSON with stable question ids, frozen on publish | an edited question rewrites the meaning of old answers | | Uploads | Presigned direct-to-storage, never through the API route | a serverless request body is far smaller than a CV or a portfolio | | Delivery | An outbox table drained with backoff, each delivery carrying a stable id | the receiver needs something to deduplicate on | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the outbox worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **S3-compatible object storage with presigned uploads** · pennies per month at personal volume - Why: Uploads have to bypass your API route, and presigned direct upload is the documented way to do it. - Get it: Any S3-compatible provider, or MinIO locally for development. - Verify: A presigned PUT from a browser stores a file you can then read by key - [ ] **A transactional email provider key** · free tier at personal volume on most providers - Why: Notification email goes out from the outbox, and a provider gives you delivery reporting you would otherwise have to build. - Get it: Create an account, verify a sending domain, and issue a key limited to sending. - Verify: A test message arrives and the provider reports it accepted - [ ] **Your platform's request body limit, response cap and retention** · free - Why: Each of these is asserted by a test, and choosing them after seeing a failure means choosing whatever passes. - Get it: Read your hosting platform's current published request body limit, then write down your per-form response cap and how long uploads are kept. - Verify: All three are in the configuration and named in tests - [ ] **A webhook receiver you can break on purpose** · free - Why: The outbox is only testable against a receiver that times out, returns errors, and then recovers. - Get it: A tiny local server with switchable behaviour, or any request bin you control. - Verify: You can make it fail for an hour and then succeed ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for Youform, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Youform The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a one-question-at-a-time form that cannot lose a submission to replace Youform. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule. A response that reached your server is never lost. Everything else here can fail and be retried: the notification email, the webhook, the spreadsheet sync, the redirect to a thank-you page. The answer itself cannot. So the submission is written and committed before any of those is attempted, and every side effect is a row in an outbox that a worker drains. If the receiving endpoint is down for a day, the responses are still on disk and the webhooks go out when it comes back. The same rule applied to the schema: a published form version is immutable. Editing a question in place turns last week's answers into data whose question nobody can reconstruct, and an export that mixes them is worse than no export. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus one worker process for the outbox · the worker is what makes 'store first' possible - Storage: SQLite through better-sqlite3, one file, written synchronously on submit · the submit path should touch nothing that can time out - Schema: Versioned JSON with stable question ids, frozen on publish · an edited question rewrites the meaning of old answers - Uploads: Presigned direct-to-storage, never through the API route · a serverless request body is far smaller than a CV or a portfolio - Delivery: An outbox table drained with backoff, each delivery carrying a stable id · the receiver needs something to deduplicate on ### Data model (create this before Phase 1) - forms(id, title, slug, current_version_id, created_at, closes_at, response_cap) - form_versions(id, form_id, ordinal, schema_json, published_at) · published rows are never updated, only superseded - schema_json holds questions(id, type, prompt, required, validation_json, logic_json) · ids stay stable across versions so answers can be compared - responses(id, form_id, form_version_id, started_at, submitted_at, resume_token_hash, state) - answers(response_id, question_id, value_json) · stored against the version that asked the question - uploads(id, response_id, question_id, storage_key, filename, bytes, sniffed_type, sha256) - outbox(id, response_id, kind, payload_json, delivery_id, attempts, next_attempt_at, delivered_at, last_error) - Answers key on question id plus version, so renaming a question never silently merges two different questions into one export column. ### Phase 1 · A schema you can edit without rewriting history Build: Store the form as JSON with stable question ids. Publishing writes an immutable version row; editing afterwards creates the next version. Each response points at the version it saw. Export reconciles versions into columns without merging two different questions. Trap: Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. Done when: A published version row is never updated, asserted by a test that attempts it. Editing a published form produces a new version and leaves existing responses pointing at the old one. An export of a form with two published versions keeps both versions' questions distinguishable rather than collapsing them. Question ids survive a prompt rewrite, so the same column follows the question. Do not build yet: No public fill page, no submission, no uploads. ### Phase 2 · Filling it in, and coming back tomorrow Build: Render one question at a time from the published schema, saving each answer as it is given against an unguessable resume token. Validate on the server from the same schema the page rendered. Check the logic graph at publish time for a branch that skips a required question. Trap: Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. Done when: A submission posted directly to the endpoint with an out-of-range value is rejected and names the offending question id, tested without a browser. A half-filled response resumes with the answers already given, and a stale token is rejected. Publishing a form whose logic skips a required question fails with the path that does it. Every validation rule the page enforces is enforced again on the server, verified by review. Do not build yet: No webhooks, notifications or uploads yet. ### Phase 3 · Store first, then everything else Build: Write the response and its answers in one transaction, commit, then return. Enqueue the webhook and the notification as outbox rows with a stable delivery id. A worker drains the outbox with exponential backoff, signs each webhook, and retries only on failure. Trap: Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. Done when: The submit path performs no network call, verified by a test that fails the run if one is attempted. A webhook receiver returning errors for an hour leaves every response intact and delivers when it recovers, tested with a fake receiver. Killing the worker mid-delivery redelivers with the same delivery id so the receiver can deduplicate. Backoff is bounded and a permanently failing row surfaces on a page rather than retrying forever in silence. Do not build yet: No uploads, export or rate limiting yet. ### Phase 4 · Uploads that never touch your API route Build: Issue a short-lived presigned upload URL scoped to one response and one question. The browser puts the file straight to storage and posts back the key. Enforce the size cap in the storage policy as well as the browser, and record the type by sniffing the stored bytes. Trap: Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. Done when: A file larger than the hosting platform's request body limit uploads and attaches to a response, tested with a file over that limit. The presigned URL is scoped to one response and one question and expires, tested by reusing it for another. The recorded type comes from sniffing the stored bytes, so a file renamed to look like a PDF is recorded as what it actually is. A file over the configured cap is refused by the storage policy, not only by the page. Do not build yet: No export, deletion or rate limiting yet. ### Phase 5 · Whose data it is, and who can knock Build: Export CSV and JSON including upload keys, and a bundle that contains the files themselves. Deleting a response deletes its stored files too. Close a form on a date or a response cap. Rate limit per address and per form, and put the responses view behind authentication. Trap: A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. Done when: An export round trips: the bundle re-imports into an empty instance with answers, versions and files intact, tested. Deleting a response removes its stored files, verified by fetching the key afterwards and getting nothing. A form at its response cap closes and shows a closed page rather than accepting. The responses view requires authentication and a direct request without it returns nothing, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments collected inside a form, which pulls card handling and refunds into a form builder. - A template gallery, which is content work rather than engineering and adds nothing to the loop. - Team accounts, approvals and shared workspaces, which multiply every screen by a permission model. - Running your own notification email at scale. Send through a provider you already trust and stop there; inbox placement is a separate project with its own operational burden. ### README must contain - That a published form version is immutable and each response records which version it answered - That validation is enforced on the server from the same schema the page rendered - That the submit path commits before any webhook or email is attempted, and side effects live in an outbox - That uploads go direct to storage because the hosting platform's request body limit is smaller than an ordinary attachment - That deleting a response also deletes its stored files, and that the export bundle re-imports ===== AGENTS.md ===== # Agent instructions · Youform indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, plus one worker process for the outbox, SQLite through better-sqlite3, one file, written synchronously on submit, Versioned JSON with stable question ids, frozen on publish, Presigned direct-to-storage, never through the API route, An outbox table drained with backoff, each delivery carrying a stable id. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ===== BUILD_PLAN.md ===== # Build plan · Youform A conversational form builder whose hard problems are changing a form without corrupting answers already collected, getting every submission onto disk before anything else is attempted, and accepting file uploads larger than a serverless request body. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A schema you can edit without rewriting history Publishing freezes a version and every response records which version it answered. ### Steps 1. Freeze a version on publish Immutability is enforced in the data layer, not by remembering. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Keep question ids stable across edits The id is the column; the prompt is just the label. Files: `src/lib/schema.ts` 3. Reconcile versions at export time Two versions of one question are two columns, not an average. Files: `src/lib/export.ts`, `tests/export-versions.test.ts` ### Done when - [ ] A published version cannot be updated - [ ] Editing creates the next version - [ ] Old responses still point at their version - [ ] Export keeps two versions' questions distinct ### Watch out - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. ## Phase 2 · Filling it in, and coming back tomorrow One question at a time, resumable, with validation that the browser cannot talk its way past. ### Steps 1. Validate server-side from the version's schema One rule set, applied twice, the second time authoritatively. Files: `src/lib/validate.ts`, `tests/server-validation.test.ts` 2. Save answers as they are given, behind a resume token A long form abandoned at question nine should not start again at one. Files: `src/app/api/answer/route.ts` 3. Walk the logic graph at publish Unreachable questions and skipped required ones are publish-time errors. Files: `src/lib/logic.ts`, `tests/logic-graph.test.ts` ### Done when - [ ] A direct post with an invalid value is rejected by question id - [ ] A partial response resumes - [ ] Logic that skips a required question fails at publish - [ ] Every client rule is re-applied on the server ### Watch out - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. ## Phase 3 · Store first, then everything else Submitting commits the response before any webhook or email is attempted. ### Steps 1. Commit the response, then return No network call may sit between the respondent and a success page. Files: `src/app/api/submit/route.ts`, `tests/no-network-on-submit.test.ts` 2. Enqueue side effects with a stable delivery id The id is what lets a receiver ignore the duplicate. Files: `src/lib/outbox.ts` 3. Drain with bounded backoff and a dead letter view A row failing forever should be visible, not invisible. Files: `worker/drain.ts`, `src/app/deliveries/page.tsx` ### Done when - [ ] The submit path makes no network call - [ ] A failing receiver never costs a response - [ ] Redelivery carries the same delivery id - [ ] Permanently failing rows are visible ### Watch out - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. ## Phase 4 · Uploads that never touch your API route A large file attaches to a response without passing through the request body. ### Steps 1. Presign per response and per question, briefly A reusable URL is an open upload endpoint. Files: `src/app/api/upload-url/route.ts`, `tests/presign-scope.test.ts` 2. Cap size in the storage policy A page attribute is a suggestion; the policy is the limit. Files: `src/lib/storage.ts` 3. Sniff the stored bytes for the type The filename is whatever the respondent typed. Files: `src/lib/sniff.ts`, `tests/sniff.test.ts` ### Done when - [ ] A file over the platform body limit attaches successfully - [ ] A presigned URL is single-purpose and expires - [ ] The recorded type comes from the bytes - [ ] The size cap is enforced by the storage policy ### Watch out - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ## Not in this build - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. ## After v1, if you want it - Partial response analytics showing where people abandon, computed from the answers you already store - A second delivery target such as a spreadsheet, added as an outbox kind and nothing else - Scheduled exports to storage you control, reusing the bundle format ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. The whole product. DATABASE_PATH=./data/forms.db # Required. Where presigned uploads go. Never through the API route. STORAGE_ENDPOINT=https://s3.eu-west-1.amazonaws.com # Required · secret. Used only to sign upload URLs and to delete on request. STORAGE_ACCESS_KEY=AKIA...redacted # Required · secret. Never reaches the browser. Presigned URLs do. STORAGE_SECRET_KEY=redacted # Required. Enforced in the storage policy as well as the page. UPLOAD_MAX_BYTES=26214400 # Required · secret. Notification email, sent from the outbox worker. EMAIL_API_KEY=re_...redacted # Required. After this a row is dead lettered and shown, not retried forever. OUTBOX_MAX_ATTEMPTS=12 # Required. Per address, per form. The thing that stops the overnight script. RATE_LIMIT_PER_HOUR=30
You are building a production product version of Youform. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Youform · product brief ## Problem Youform's solo core is compact: build a conversational forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance. ## Product outcome A form tool where a submitted answer is on disk before anything else happens, a form can be edited without corrupting last month's responses, large files upload without touching the API, and a public URL cannot be filled ten thousand times while you sleep. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic ## Success criteria - A published version is confirmed immutable - A direct post with an invalid value is confirmed rejected server-side - A failing webhook receiver is confirmed to cost no responses - A file over the platform body limit is confirmed to upload - An export bundle is confirmed to re-import intact ===== BRIEF.md ===== # Build brief · Youform The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a one-question-at-a-time form that cannot lose a submission to replace Youform. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule. A response that reached your server is never lost. Everything else here can fail and be retried: the notification email, the webhook, the spreadsheet sync, the redirect to a thank-you page. The answer itself cannot. So the submission is written and committed before any of those is attempted, and every side effect is a row in an outbox that a worker drains. If the receiving endpoint is down for a day, the responses are still on disk and the webhooks go out when it comes back. The same rule applied to the schema: a published form version is immutable. Editing a question in place turns last week's answers into data whose question nobody can reconstruct, and an export that mixes them is worse than no export. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus one worker process for the outbox · the worker is what makes 'store first' possible - Storage: SQLite through better-sqlite3, one file, written synchronously on submit · the submit path should touch nothing that can time out - Schema: Versioned JSON with stable question ids, frozen on publish · an edited question rewrites the meaning of old answers - Uploads: Presigned direct-to-storage, never through the API route · a serverless request body is far smaller than a CV or a portfolio - Delivery: An outbox table drained with backoff, each delivery carrying a stable id · the receiver needs something to deduplicate on ### Data model (create this before Phase 1) - forms(id, title, slug, current_version_id, created_at, closes_at, response_cap) - form_versions(id, form_id, ordinal, schema_json, published_at) · published rows are never updated, only superseded - schema_json holds questions(id, type, prompt, required, validation_json, logic_json) · ids stay stable across versions so answers can be compared - responses(id, form_id, form_version_id, started_at, submitted_at, resume_token_hash, state) - answers(response_id, question_id, value_json) · stored against the version that asked the question - uploads(id, response_id, question_id, storage_key, filename, bytes, sniffed_type, sha256) - outbox(id, response_id, kind, payload_json, delivery_id, attempts, next_attempt_at, delivered_at, last_error) - Answers key on question id plus version, so renaming a question never silently merges two different questions into one export column. ### Phase 1 · A schema you can edit without rewriting history Build: Store the form as JSON with stable question ids. Publishing writes an immutable version row; editing afterwards creates the next version. Each response points at the version it saw. Export reconciles versions into columns without merging two different questions. Trap: Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. Done when: A published version row is never updated, asserted by a test that attempts it. Editing a published form produces a new version and leaves existing responses pointing at the old one. An export of a form with two published versions keeps both versions' questions distinguishable rather than collapsing them. Question ids survive a prompt rewrite, so the same column follows the question. Do not build yet: No public fill page, no submission, no uploads. ### Phase 2 · Filling it in, and coming back tomorrow Build: Render one question at a time from the published schema, saving each answer as it is given against an unguessable resume token. Validate on the server from the same schema the page rendered. Check the logic graph at publish time for a branch that skips a required question. Trap: Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. Done when: A submission posted directly to the endpoint with an out-of-range value is rejected and names the offending question id, tested without a browser. A half-filled response resumes with the answers already given, and a stale token is rejected. Publishing a form whose logic skips a required question fails with the path that does it. Every validation rule the page enforces is enforced again on the server, verified by review. Do not build yet: No webhooks, notifications or uploads yet. ### Phase 3 · Store first, then everything else Build: Write the response and its answers in one transaction, commit, then return. Enqueue the webhook and the notification as outbox rows with a stable delivery id. A worker drains the outbox with exponential backoff, signs each webhook, and retries only on failure. Trap: Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. Done when: The submit path performs no network call, verified by a test that fails the run if one is attempted. A webhook receiver returning errors for an hour leaves every response intact and delivers when it recovers, tested with a fake receiver. Killing the worker mid-delivery redelivers with the same delivery id so the receiver can deduplicate. Backoff is bounded and a permanently failing row surfaces on a page rather than retrying forever in silence. Do not build yet: No uploads, export or rate limiting yet. ### Phase 4 · Uploads that never touch your API route Build: Issue a short-lived presigned upload URL scoped to one response and one question. The browser puts the file straight to storage and posts back the key. Enforce the size cap in the storage policy as well as the browser, and record the type by sniffing the stored bytes. Trap: Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. Done when: A file larger than the hosting platform's request body limit uploads and attaches to a response, tested with a file over that limit. The presigned URL is scoped to one response and one question and expires, tested by reusing it for another. The recorded type comes from sniffing the stored bytes, so a file renamed to look like a PDF is recorded as what it actually is. A file over the configured cap is refused by the storage policy, not only by the page. Do not build yet: No export, deletion or rate limiting yet. ### Phase 5 · Whose data it is, and who can knock Build: Export CSV and JSON including upload keys, and a bundle that contains the files themselves. Deleting a response deletes its stored files too. Close a form on a date or a response cap. Rate limit per address and per form, and put the responses view behind authentication. Trap: A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. Done when: An export round trips: the bundle re-imports into an empty instance with answers, versions and files intact, tested. Deleting a response removes its stored files, verified by fetching the key afterwards and getting nothing. A form at its response cap closes and shows a closed page rather than accepting. The responses view requires authentication and a direct request without it returns nothing, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments collected inside a form, which pulls card handling and refunds into a form builder. - A template gallery, which is content work rather than engineering and adds nothing to the loop. - Team accounts, approvals and shared workspaces, which multiply every screen by a permission model. - Running your own notification email at scale. Send through a provider you already trust and stop there; inbox placement is a separate project with its own operational burden. ### README must contain - That a published form version is immutable and each response records which version it answered - That validation is enforced on the server from the same schema the page rendered - That the submit path commits before any webhook or email is attempted, and side effects live in an outbox - That uploads go direct to storage because the hosting platform's request body limit is smaller than an ordinary attachment - That deleting a response also deletes its stored files, and that the export bundle re-imports ===== ARCHITECTURE.md ===== # Architecture · Youform ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus one worker process for the outbox | the worker is what makes 'store first' possible | | Storage | SQLite through better-sqlite3, one file, written synchronously on submit | the submit path should touch nothing that can time out | | Schema | Versioned JSON with stable question ids, frozen on publish | an edited question rewrites the meaning of old answers | | Uploads | Presigned direct-to-storage, never through the API route | a serverless request body is far smaller than a CV or a portfolio | | Delivery | An outbox table drained with backoff, each delivery carrying a stable id | the receiver needs something to deduplicate on | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Versioned questions with stable ids. | Any storage, provided published versions stay immutable. | | Fill | One question at a time with resume. | Any front end, provided the server revalidates everything. | | Submit | The transaction that must not fail. | Not swappable for anything that makes a network call first. | | Outbox | Webhooks and notifications with delivery ids. | Any queue, provided redelivery reuses the id. | | Uploads | Presigned direct-to-storage and type sniffing. | Any storage with presigning and a policy-level size cap. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, responses and the outbox. The whole product. - `STORAGE_ENDPOINT` · required · Where presigned uploads go. Never through the API route. - `STORAGE_ACCESS_KEY` · required, secret · Used only to sign upload URLs and to delete on request. - `STORAGE_SECRET_KEY` · required, secret · Never reaches the browser. Presigned URLs do. - `UPLOAD_MAX_BYTES` · required · Enforced in the storage policy as well as the page. - `EMAIL_API_KEY` · required, secret · Notification email, sent from the outbox worker. - `OUTBOX_MAX_ATTEMPTS` · required · After this a row is dead lettered and shown, not retried forever. - `RATE_LIMIT_PER_HOUR` · required · Per address, per form. The thing that stops the overnight script. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Youform product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, plus one worker process for the outbox, SQLite through better-sqlite3, one file, written synchronously on submit, Versioned JSON with stable question ids, frozen on publish, Presigned direct-to-storage, never through the API route, An outbox table drained with backoff, each delivery carrying a stable id. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. - A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. ===== MILESTONES.md ===== # Delivery milestones · Youform Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A schema you can edit without rewriting history Publishing freezes a version and every response records which version it answered. ### Steps 1. Freeze a version on publish Immutability is enforced in the data layer, not by remembering. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Keep question ids stable across edits The id is the column; the prompt is just the label. Files: `src/lib/schema.ts` 3. Reconcile versions at export time Two versions of one question are two columns, not an average. Files: `src/lib/export.ts`, `tests/export-versions.test.ts` ### Done when - [ ] A published version cannot be updated - [ ] Editing creates the next version - [ ] Old responses still point at their version - [ ] Export keeps two versions' questions distinct ### Watch out - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. ## M2 · Filling it in, and coming back tomorrow One question at a time, resumable, with validation that the browser cannot talk its way past. ### Steps 1. Validate server-side from the version's schema One rule set, applied twice, the second time authoritatively. Files: `src/lib/validate.ts`, `tests/server-validation.test.ts` 2. Save answers as they are given, behind a resume token A long form abandoned at question nine should not start again at one. Files: `src/app/api/answer/route.ts` 3. Walk the logic graph at publish Unreachable questions and skipped required ones are publish-time errors. Files: `src/lib/logic.ts`, `tests/logic-graph.test.ts` ### Done when - [ ] A direct post with an invalid value is rejected by question id - [ ] A partial response resumes - [ ] Logic that skips a required question fails at publish - [ ] Every client rule is re-applied on the server ### Watch out - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. ## M3 · Store first, then everything else Submitting commits the response before any webhook or email is attempted. ### Steps 1. Commit the response, then return No network call may sit between the respondent and a success page. Files: `src/app/api/submit/route.ts`, `tests/no-network-on-submit.test.ts` 2. Enqueue side effects with a stable delivery id The id is what lets a receiver ignore the duplicate. Files: `src/lib/outbox.ts` 3. Drain with bounded backoff and a dead letter view A row failing forever should be visible, not invisible. Files: `worker/drain.ts`, `src/app/deliveries/page.tsx` ### Done when - [ ] The submit path makes no network call - [ ] A failing receiver never costs a response - [ ] Redelivery carries the same delivery id - [ ] Permanently failing rows are visible ### Watch out - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. ## M4 · Uploads that never touch your API route A large file attaches to a response without passing through the request body. ### Steps 1. Presign per response and per question, briefly A reusable URL is an open upload endpoint. Files: `src/app/api/upload-url/route.ts`, `tests/presign-scope.test.ts` 2. Cap size in the storage policy A page attribute is a suggestion; the policy is the limit. Files: `src/lib/storage.ts` 3. Sniff the stored bytes for the type The filename is whatever the respondent typed. Files: `src/lib/sniff.ts`, `tests/sniff.test.ts` ### Done when - [ ] A file over the platform body limit attaches successfully - [ ] A presigned URL is single-purpose and expires - [ ] The recorded type comes from the bytes - [ ] The size cap is enforced by the storage policy ### Watch out - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ## M5 · Whose data it is, and who can knock (production only) Responses export whole, delete whole, and a public URL cannot be filled ten thousand times overnight. ### Steps 1. Export a bundle that re-imports An export nobody has imported is a guess. Files: `src/lib/bundle.ts`, `tests/roundtrip.test.ts` 2. Delete files when deleting a response An orphaned upload is still personal data. Files: `src/lib/delete.ts` 3. Cap, close and rate limit The cap is the thing that stops the bill. Files: `src/lib/limits.ts`, `tests/rate-limit.test.ts` ### Done when - [ ] An exported bundle re-imports intact - [ ] Deleting a response deletes its files - [ ] A capped form closes rather than accepting - [ ] The responses view requires authentication ### Watch out - A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. ===== OPERATIONS.md ===== # Operations · Youform ## Backup The database and the object storage together. Restoring one without the other gives you responses referencing files that are gone, or files nobody can attribute. Take both on the same schedule. ## Restore Restore both, then run the outbox worker and confirm undelivered rows deliver rather than duplicating. Re-import an export bundle into an empty instance as the actual proof the backup is usable. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Outbox depth, dead lettered rows, forms near their response cap, and the age of the oldest undelivered row. A silent queue is the failure that looks like nothing is wrong. ## Incident checklist A missing response means the submit transaction failed, and the request log will say so. A missing webhook means the outbox is stuck, and the dead letter view names the row and the last error. Neither is diagnosed by guessing. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A published version is confirmed immutable - [ ] A direct post with an invalid value is confirmed rejected server-side - [ ] A failing webhook receiver is confirmed to cost no responses - [ ] A file over the platform body limit is confirmed to upload - [ ] An export bundle is confirmed to re-import intact ## Launch constraint Do not market omitted Youform capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. The whole product. DATABASE_PATH=./data/forms.db # Required. Where presigned uploads go. Never through the API route. STORAGE_ENDPOINT=https://s3.eu-west-1.amazonaws.com # Required · secret. Used only to sign upload URLs and to delete on request. STORAGE_ACCESS_KEY=AKIA...redacted # Required · secret. Never reaches the browser. Presigned URLs do. STORAGE_SECRET_KEY=redacted # Required. Enforced in the storage policy as well as the page. UPLOAD_MAX_BYTES=26214400 # Required · secret. Notification email, sent from the outbox worker. EMAIL_API_KEY=re_...redacted # Required. After this a row is dead lettered and shown, not retried forever. OUTBOX_MAX_ATTEMPTS=12 # Required. Per address, per form. The thing that stops the overnight script. RATE_LIMIT_PER_HOUR=30
# Youform · indie build A conversational form builder whose hard problems are changing a form without corrupting answers already collected, getting every submission onto disk before anything else is attempted, and accepting file uploads larger than a serverless request body. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus one worker process for the outbox | the worker is what makes 'store first' possible | | Storage | SQLite through better-sqlite3, one file, written synchronously on submit | the submit path should touch nothing that can time out | | Schema | Versioned JSON with stable question ids, frozen on publish | an edited question rewrites the meaning of old answers | | Uploads | Presigned direct-to-storage, never through the API route | a serverless request body is far smaller than a CV or a portfolio | | Delivery | An outbox table drained with backoff, each delivery carrying a stable id | the receiver needs something to deduplicate on | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the outbox worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **S3-compatible object storage with presigned uploads** · pennies per month at personal volume - Why: Uploads have to bypass your API route, and presigned direct upload is the documented way to do it. - Get it: Any S3-compatible provider, or MinIO locally for development. - Verify: A presigned PUT from a browser stores a file you can then read by key - [ ] **A transactional email provider key** · free tier at personal volume on most providers - Why: Notification email goes out from the outbox, and a provider gives you delivery reporting you would otherwise have to build. - Get it: Create an account, verify a sending domain, and issue a key limited to sending. - Verify: A test message arrives and the provider reports it accepted - [ ] **Your platform's request body limit, response cap and retention** · free - Why: Each of these is asserted by a test, and choosing them after seeing a failure means choosing whatever passes. - Get it: Read your hosting platform's current published request body limit, then write down your per-form response cap and how long uploads are kept. - Verify: All three are in the configuration and named in tests - [ ] **A webhook receiver you can break on purpose** · free - Why: The outbox is only testable against a receiver that times out, returns errors, and then recovers. - Get it: A tiny local server with switchable behaviour, or any request bin you control. - Verify: You can make it fail for an hour and then succeed ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic If one of those is essential to you, that is the reason to keep paying for Youform, and the README should say so rather than pretend.
# Build brief · Youform The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a one-question-at-a-time form that cannot lose a submission to replace Youform. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule. A response that reached your server is never lost. Everything else here can fail and be retried: the notification email, the webhook, the spreadsheet sync, the redirect to a thank-you page. The answer itself cannot. So the submission is written and committed before any of those is attempted, and every side effect is a row in an outbox that a worker drains. If the receiving endpoint is down for a day, the responses are still on disk and the webhooks go out when it comes back. The same rule applied to the schema: a published form version is immutable. Editing a question in place turns last week's answers into data whose question nobody can reconstruct, and an export that mixes them is worse than no export. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus one worker process for the outbox · the worker is what makes 'store first' possible - Storage: SQLite through better-sqlite3, one file, written synchronously on submit · the submit path should touch nothing that can time out - Schema: Versioned JSON with stable question ids, frozen on publish · an edited question rewrites the meaning of old answers - Uploads: Presigned direct-to-storage, never through the API route · a serverless request body is far smaller than a CV or a portfolio - Delivery: An outbox table drained with backoff, each delivery carrying a stable id · the receiver needs something to deduplicate on ### Data model (create this before Phase 1) - forms(id, title, slug, current_version_id, created_at, closes_at, response_cap) - form_versions(id, form_id, ordinal, schema_json, published_at) · published rows are never updated, only superseded - schema_json holds questions(id, type, prompt, required, validation_json, logic_json) · ids stay stable across versions so answers can be compared - responses(id, form_id, form_version_id, started_at, submitted_at, resume_token_hash, state) - answers(response_id, question_id, value_json) · stored against the version that asked the question - uploads(id, response_id, question_id, storage_key, filename, bytes, sniffed_type, sha256) - outbox(id, response_id, kind, payload_json, delivery_id, attempts, next_attempt_at, delivered_at, last_error) - Answers key on question id plus version, so renaming a question never silently merges two different questions into one export column. ### Phase 1 · A schema you can edit without rewriting history Build: Store the form as JSON with stable question ids. Publishing writes an immutable version row; editing afterwards creates the next version. Each response points at the version it saw. Export reconciles versions into columns without merging two different questions. Trap: Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. Done when: A published version row is never updated, asserted by a test that attempts it. Editing a published form produces a new version and leaves existing responses pointing at the old one. An export of a form with two published versions keeps both versions' questions distinguishable rather than collapsing them. Question ids survive a prompt rewrite, so the same column follows the question. Do not build yet: No public fill page, no submission, no uploads. ### Phase 2 · Filling it in, and coming back tomorrow Build: Render one question at a time from the published schema, saving each answer as it is given against an unguessable resume token. Validate on the server from the same schema the page rendered. Check the logic graph at publish time for a branch that skips a required question. Trap: Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. Done when: A submission posted directly to the endpoint with an out-of-range value is rejected and names the offending question id, tested without a browser. A half-filled response resumes with the answers already given, and a stale token is rejected. Publishing a form whose logic skips a required question fails with the path that does it. Every validation rule the page enforces is enforced again on the server, verified by review. Do not build yet: No webhooks, notifications or uploads yet. ### Phase 3 · Store first, then everything else Build: Write the response and its answers in one transaction, commit, then return. Enqueue the webhook and the notification as outbox rows with a stable delivery id. A worker drains the outbox with exponential backoff, signs each webhook, and retries only on failure. Trap: Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. Done when: The submit path performs no network call, verified by a test that fails the run if one is attempted. A webhook receiver returning errors for an hour leaves every response intact and delivers when it recovers, tested with a fake receiver. Killing the worker mid-delivery redelivers with the same delivery id so the receiver can deduplicate. Backoff is bounded and a permanently failing row surfaces on a page rather than retrying forever in silence. Do not build yet: No uploads, export or rate limiting yet. ### Phase 4 · Uploads that never touch your API route Build: Issue a short-lived presigned upload URL scoped to one response and one question. The browser puts the file straight to storage and posts back the key. Enforce the size cap in the storage policy as well as the browser, and record the type by sniffing the stored bytes. Trap: Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. Done when: A file larger than the hosting platform's request body limit uploads and attaches to a response, tested with a file over that limit. The presigned URL is scoped to one response and one question and expires, tested by reusing it for another. The recorded type comes from sniffing the stored bytes, so a file renamed to look like a PDF is recorded as what it actually is. A file over the configured cap is refused by the storage policy, not only by the page. Do not build yet: No export, deletion or rate limiting yet. ### Phase 5 · Whose data it is, and who can knock Build: Export CSV and JSON including upload keys, and a bundle that contains the files themselves. Deleting a response deletes its stored files too. Close a form on a date or a response cap. Rate limit per address and per form, and put the responses view behind authentication. Trap: A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. Done when: An export round trips: the bundle re-imports into an empty instance with answers, versions and files intact, tested. Deleting a response removes its stored files, verified by fetching the key afterwards and getting nothing. A form at its response cap closes and shows a closed page rather than accepting. The responses view requires authentication and a direct request without it returns nothing, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments collected inside a form, which pulls card handling and refunds into a form builder. - A template gallery, which is content work rather than engineering and adds nothing to the loop. - Team accounts, approvals and shared workspaces, which multiply every screen by a permission model. - Running your own notification email at scale. Send through a provider you already trust and stop there; inbox placement is a separate project with its own operational burden. ### README must contain - That a published form version is immutable and each response records which version it answered - That validation is enforced on the server from the same schema the page rendered - That the submit path commits before any webhook or email is attempted, and side effects live in an outbox - That uploads go direct to storage because the hosting platform's request body limit is smaller than an ordinary attachment - That deleting a response also deletes its stored files, and that the export bundle re-imports
# Agent instructions · Youform indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, plus one worker process for the outbox, SQLite through better-sqlite3, one file, written synchronously on submit, Versioned JSON with stable question ids, frozen on publish, Presigned direct-to-storage, never through the API route, An outbox table drained with backoff, each delivery carrying a stable id. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute.
# Build plan · Youform A conversational form builder whose hard problems are changing a form without corrupting answers already collected, getting every submission onto disk before anything else is attempted, and accepting file uploads larger than a serverless request body. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A schema you can edit without rewriting history Publishing freezes a version and every response records which version it answered. ### Steps 1. Freeze a version on publish Immutability is enforced in the data layer, not by remembering. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Keep question ids stable across edits The id is the column; the prompt is just the label. Files: `src/lib/schema.ts` 3. Reconcile versions at export time Two versions of one question are two columns, not an average. Files: `src/lib/export.ts`, `tests/export-versions.test.ts` ### Done when - [ ] A published version cannot be updated - [ ] Editing creates the next version - [ ] Old responses still point at their version - [ ] Export keeps two versions' questions distinct ### Watch out - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. ## Phase 2 · Filling it in, and coming back tomorrow One question at a time, resumable, with validation that the browser cannot talk its way past. ### Steps 1. Validate server-side from the version's schema One rule set, applied twice, the second time authoritatively. Files: `src/lib/validate.ts`, `tests/server-validation.test.ts` 2. Save answers as they are given, behind a resume token A long form abandoned at question nine should not start again at one. Files: `src/app/api/answer/route.ts` 3. Walk the logic graph at publish Unreachable questions and skipped required ones are publish-time errors. Files: `src/lib/logic.ts`, `tests/logic-graph.test.ts` ### Done when - [ ] A direct post with an invalid value is rejected by question id - [ ] A partial response resumes - [ ] Logic that skips a required question fails at publish - [ ] Every client rule is re-applied on the server ### Watch out - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. ## Phase 3 · Store first, then everything else Submitting commits the response before any webhook or email is attempted. ### Steps 1. Commit the response, then return No network call may sit between the respondent and a success page. Files: `src/app/api/submit/route.ts`, `tests/no-network-on-submit.test.ts` 2. Enqueue side effects with a stable delivery id The id is what lets a receiver ignore the duplicate. Files: `src/lib/outbox.ts` 3. Drain with bounded backoff and a dead letter view A row failing forever should be visible, not invisible. Files: `worker/drain.ts`, `src/app/deliveries/page.tsx` ### Done when - [ ] The submit path makes no network call - [ ] A failing receiver never costs a response - [ ] Redelivery carries the same delivery id - [ ] Permanently failing rows are visible ### Watch out - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. ## Phase 4 · Uploads that never touch your API route A large file attaches to a response without passing through the request body. ### Steps 1. Presign per response and per question, briefly A reusable URL is an open upload endpoint. Files: `src/app/api/upload-url/route.ts`, `tests/presign-scope.test.ts` 2. Cap size in the storage policy A page attribute is a suggestion; the policy is the limit. Files: `src/lib/storage.ts` 3. Sniff the stored bytes for the type The filename is whatever the respondent typed. Files: `src/lib/sniff.ts`, `tests/sniff.test.ts` ### Done when - [ ] A file over the platform body limit attaches successfully - [ ] A presigned URL is single-purpose and expires - [ ] The recorded type comes from the bytes - [ ] The size cap is enforced by the storage policy ### Watch out - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ## Not in this build - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. ## After v1, if you want it - Partial response analytics showing where people abandon, computed from the answers you already store - A second delivery target such as a spreadsheet, added as an outbox kind and nothing else - Scheduled exports to storage you control, reusing the bundle format
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. The whole product. DATABASE_PATH=./data/forms.db # Required. Where presigned uploads go. Never through the API route. STORAGE_ENDPOINT=https://s3.eu-west-1.amazonaws.com # Required · secret. Used only to sign upload URLs and to delete on request. STORAGE_ACCESS_KEY=AKIA...redacted # Required · secret. Never reaches the browser. Presigned URLs do. STORAGE_SECRET_KEY=redacted # Required. Enforced in the storage policy as well as the page. UPLOAD_MAX_BYTES=26214400 # Required · secret. Notification email, sent from the outbox worker. EMAIL_API_KEY=re_...redacted # Required. After this a row is dead lettered and shown, not retried forever. OUTBOX_MAX_ATTEMPTS=12 # Required. Per address, per form. The thing that stops the overnight script. RATE_LIMIT_PER_HOUR=30
# Youform · product brief ## Problem Youform's solo core is compact: build a conversational forms tool with a schema-based form editor, public share link, validation, response table, CSV export, and webhook. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on delivery, integrations, compliance. ## Product outcome A form tool where a submitted answer is on disk before anything else happens, a form can be edited without corrupting last month's responses, large files upload without touching the API, and a public URL cannot be filled ten thousand times while you sleep. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - deploy target - SMTP or transactional email key in .env ## Explicit non-goals for v1 - Payments inside forms. - A template gallery. - Team accounts and approvals. - Your own email sending infrastructure. - spam and abuse defense at scale - large integration and template catalog - compliance controls and guaranteed delivery - enterprise workflow logic ## Success criteria - A published version is confirmed immutable - A direct post with an invalid value is confirmed rejected server-side - A failing webhook receiver is confirmed to cost no responses - A file over the platform body limit is confirmed to upload - An export bundle is confirmed to re-import intact
# Build brief · Youform The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a one-question-at-a-time form that cannot lose a submission to replace Youform. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero One rule. A response that reached your server is never lost. Everything else here can fail and be retried: the notification email, the webhook, the spreadsheet sync, the redirect to a thank-you page. The answer itself cannot. So the submission is written and committed before any of those is attempted, and every side effect is a row in an outbox that a worker drains. If the receiving endpoint is down for a day, the responses are still on disk and the webhooks go out when it comes back. The same rule applied to the schema: a published form version is immutable. Editing a question in place turns last week's answers into data whose question nobody can reconstruct, and an export that mixes them is worse than no export. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus one worker process for the outbox · the worker is what makes 'store first' possible - Storage: SQLite through better-sqlite3, one file, written synchronously on submit · the submit path should touch nothing that can time out - Schema: Versioned JSON with stable question ids, frozen on publish · an edited question rewrites the meaning of old answers - Uploads: Presigned direct-to-storage, never through the API route · a serverless request body is far smaller than a CV or a portfolio - Delivery: An outbox table drained with backoff, each delivery carrying a stable id · the receiver needs something to deduplicate on ### Data model (create this before Phase 1) - forms(id, title, slug, current_version_id, created_at, closes_at, response_cap) - form_versions(id, form_id, ordinal, schema_json, published_at) · published rows are never updated, only superseded - schema_json holds questions(id, type, prompt, required, validation_json, logic_json) · ids stay stable across versions so answers can be compared - responses(id, form_id, form_version_id, started_at, submitted_at, resume_token_hash, state) - answers(response_id, question_id, value_json) · stored against the version that asked the question - uploads(id, response_id, question_id, storage_key, filename, bytes, sniffed_type, sha256) - outbox(id, response_id, kind, payload_json, delivery_id, attempts, next_attempt_at, delivered_at, last_error) - Answers key on question id plus version, so renaming a question never silently merges two different questions into one export column. ### Phase 1 · A schema you can edit without rewriting history Build: Store the form as JSON with stable question ids. Publishing writes an immutable version row; editing afterwards creates the next version. Each response points at the version it saw. Export reconciles versions into columns without merging two different questions. Trap: Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. Done when: A published version row is never updated, asserted by a test that attempts it. Editing a published form produces a new version and leaves existing responses pointing at the old one. An export of a form with two published versions keeps both versions' questions distinguishable rather than collapsing them. Question ids survive a prompt rewrite, so the same column follows the question. Do not build yet: No public fill page, no submission, no uploads. ### Phase 2 · Filling it in, and coming back tomorrow Build: Render one question at a time from the published schema, saving each answer as it is given against an unguessable resume token. Validate on the server from the same schema the page rendered. Check the logic graph at publish time for a branch that skips a required question. Trap: Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. Done when: A submission posted directly to the endpoint with an out-of-range value is rejected and names the offending question id, tested without a browser. A half-filled response resumes with the answers already given, and a stale token is rejected. Publishing a form whose logic skips a required question fails with the path that does it. Every validation rule the page enforces is enforced again on the server, verified by review. Do not build yet: No webhooks, notifications or uploads yet. ### Phase 3 · Store first, then everything else Build: Write the response and its answers in one transaction, commit, then return. Enqueue the webhook and the notification as outbox rows with a stable delivery id. A worker drains the outbox with exponential backoff, signs each webhook, and retries only on failure. Trap: Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. Done when: The submit path performs no network call, verified by a test that fails the run if one is attempted. A webhook receiver returning errors for an hour leaves every response intact and delivers when it recovers, tested with a fake receiver. Killing the worker mid-delivery redelivers with the same delivery id so the receiver can deduplicate. Backoff is bounded and a permanently failing row surfaces on a page rather than retrying forever in silence. Do not build yet: No uploads, export or rate limiting yet. ### Phase 4 · Uploads that never touch your API route Build: Issue a short-lived presigned upload URL scoped to one response and one question. The browser puts the file straight to storage and posts back the key. Enforce the size cap in the storage policy as well as the browser, and record the type by sniffing the stored bytes. Trap: Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. Done when: A file larger than the hosting platform's request body limit uploads and attaches to a response, tested with a file over that limit. The presigned URL is scoped to one response and one question and expires, tested by reusing it for another. The recorded type comes from sniffing the stored bytes, so a file renamed to look like a PDF is recorded as what it actually is. A file over the configured cap is refused by the storage policy, not only by the page. Do not build yet: No export, deletion or rate limiting yet. ### Phase 5 · Whose data it is, and who can knock Build: Export CSV and JSON including upload keys, and a bundle that contains the files themselves. Deleting a response deletes its stored files too. Close a form on a date or a response cap. Rate limit per address and per form, and put the responses view behind authentication. Trap: A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly. Done when: An export round trips: the bundle re-imports into an empty instance with answers, versions and files intact, tested. Deleting a response removes its stored files, verified by fetching the key afterwards and getting nothing. A form at its response cap closes and shows a closed page rather than accepting. The responses view requires authentication and a direct request without it returns nothing, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Payments collected inside a form, which pulls card handling and refunds into a form builder. - A template gallery, which is content work rather than engineering and adds nothing to the loop. - Team accounts, approvals and shared workspaces, which multiply every screen by a permission model. - Running your own notification email at scale. Send through a provider you already trust and stop there; inbox placement is a separate project with its own operational burden. ### README must contain - That a published form version is immutable and each response records which version it answered - That validation is enforced on the server from the same schema the page rendered - That the submit path commits before any webhook or email is attempted, and side effects live in an outbox - That uploads go direct to storage because the hosting platform's request body limit is smaller than an ordinary attachment - That deleting a response also deletes its stored files, and that the export bundle re-imports
# Architecture · Youform ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus one worker process for the outbox | the worker is what makes 'store first' possible | | Storage | SQLite through better-sqlite3, one file, written synchronously on submit | the submit path should touch nothing that can time out | | Schema | Versioned JSON with stable question ids, frozen on publish | an edited question rewrites the meaning of old answers | | Uploads | Presigned direct-to-storage, never through the API route | a serverless request body is far smaller than a CV or a portfolio | | Delivery | An outbox table drained with backoff, each delivery carrying a stable id | the receiver needs something to deduplicate on | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Schema | Versioned questions with stable ids. | Any storage, provided published versions stay immutable. | | Fill | One question at a time with resume. | Any front end, provided the server revalidates everything. | | Submit | The transaction that must not fail. | Not swappable for anything that makes a network call first. | | Outbox | Webhooks and notifications with delivery ids. | Any queue, provided redelivery reuses the id. | | Uploads | Presigned direct-to-storage and type sniffing. | Any storage with presigning and a policy-level size cap. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Forms, versions, responses and the outbox. The whole product. - `STORAGE_ENDPOINT` · required · Where presigned uploads go. Never through the API route. - `STORAGE_ACCESS_KEY` · required, secret · Used only to sign upload URLs and to delete on request. - `STORAGE_SECRET_KEY` · required, secret · Never reaches the browser. Presigned URLs do. - `UPLOAD_MAX_BYTES` · required · Enforced in the storage policy as well as the page. - `EMAIL_API_KEY` · required, secret · Notification email, sent from the outbox worker. - `OUTBOX_MAX_ATTEMPTS` · required · After this a row is dead lettered and shown, not retried forever. - `RATE_LIMIT_PER_HOUR` · required · Per address, per form. The thing that stops the overnight script. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Youform product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, plus one worker process for the outbox, SQLite through better-sqlite3, one file, written synchronously on submit, Versioned JSON with stable question ids, frozen on publish, Presigned direct-to-storage, never through the API route, An outbox table drained with backoff, each delivery carrying a stable id. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. - A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly.
# Delivery milestones · Youform Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A schema you can edit without rewriting history Publishing freezes a version and every response records which version it answered. ### Steps 1. Freeze a version on publish Immutability is enforced in the data layer, not by remembering. Files: `src/lib/versions.ts`, `tests/immutable-version.test.ts` 2. Keep question ids stable across edits The id is the column; the prompt is just the label. Files: `src/lib/schema.ts` 3. Reconcile versions at export time Two versions of one question are two columns, not an average. Files: `src/lib/export.ts`, `tests/export-versions.test.ts` ### Done when - [ ] A published version cannot be updated - [ ] Editing creates the next version - [ ] Old responses still point at their version - [ ] Export keeps two versions' questions distinct ### Watch out - Editing the live form's questions in place rewrites the meaning of answers already collected, so an export shows text people typed when the prompt said something else entirely, and there is no record of what they were actually asked. Freeze the schema on publish and point each response at its version. ## M2 · Filling it in, and coming back tomorrow One question at a time, resumable, with validation that the browser cannot talk its way past. ### Steps 1. Validate server-side from the version's schema One rule set, applied twice, the second time authoritatively. Files: `src/lib/validate.ts`, `tests/server-validation.test.ts` 2. Save answers as they are given, behind a resume token A long form abandoned at question nine should not start again at one. Files: `src/app/api/answer/route.ts` 3. Walk the logic graph at publish Unreachable questions and skipped required ones are publish-time errors. Files: `src/lib/logic.ts`, `tests/logic-graph.test.ts` ### Done when - [ ] A direct post with an invalid value is rejected by question id - [ ] A partial response resumes - [ ] Logic that skips a required question fails at publish - [ ] Every client rule is re-applied on the server ### Watch out - Validating only in the browser means the public submit endpoint accepts anything a script sends, because the page is not a security boundary; and a conditional branch that routes around a required question produces responses missing an answer forever, with no error anywhere. Validate on the server against the version's schema, and walk the logic graph at publish for unreachable and skipped-required questions. ## M3 · Store first, then everything else Submitting commits the response before any webhook or email is attempted. ### Steps 1. Commit the response, then return No network call may sit between the respondent and a success page. Files: `src/app/api/submit/route.ts`, `tests/no-network-on-submit.test.ts` 2. Enqueue side effects with a stable delivery id The id is what lets a receiver ignore the duplicate. Files: `src/lib/outbox.ts` 3. Drain with bounded backoff and a dead letter view A row failing forever should be visible, not invisible. Files: `worker/drain.ts`, `src/app/deliveries/page.tsx` ### Done when - [ ] The submit path makes no network call - [ ] A failing receiver never costs a response - [ ] Redelivery carries the same delivery id - [ ] Permanently failing rows are visible ### Watch out - Calling the webhook inside the submit request means a slow receiver turns into a timeout the respondent sees, so they press submit again and you get two responses, or they give up and you get none. Commit first, return immediately, and deliver from the outbox. ## M4 · Uploads that never touch your API route A large file attaches to a response without passing through the request body. ### Steps 1. Presign per response and per question, briefly A reusable URL is an open upload endpoint. Files: `src/app/api/upload-url/route.ts`, `tests/presign-scope.test.ts` 2. Cap size in the storage policy A page attribute is a suggestion; the policy is the limit. Files: `src/lib/storage.ts` 3. Sniff the stored bytes for the type The filename is whatever the respondent typed. Files: `src/lib/sniff.ts`, `tests/sniff.test.ts` ### Done when - [ ] A file over the platform body limit attaches successfully - [ ] A presigned URL is single-purpose and expires - [ ] The recorded type comes from the bytes - [ ] The size cap is enforced by the storage policy ### Watch out - Posting the file to your own endpoint works on a laptop and fails in production, because the hosting platform caps the request body well below the size of an ordinary attachment: Vercel Functions, for instance, reject a request or response body over 4.5 MB with a 413 (checked September 2026), and the respondent sees a generic failure with no idea which file was too big. Upload direct to storage with a presigned URL, and enforce your own cap in the storage policy rather than in a form attribute. ## M5 · Whose data it is, and who can knock (production only) Responses export whole, delete whole, and a public URL cannot be filled ten thousand times overnight. ### Steps 1. Export a bundle that re-imports An export nobody has imported is a guess. Files: `src/lib/bundle.ts`, `tests/roundtrip.test.ts` 2. Delete files when deleting a response An orphaned upload is still personal data. Files: `src/lib/delete.ts` 3. Cap, close and rate limit The cap is the thing that stops the bill. Files: `src/lib/limits.ts`, `tests/rate-limit.test.ts` ### Done when - [ ] An exported bundle re-imports intact - [ ] Deleting a response deletes its files - [ ] A capped form closes rather than accepting - [ ] The responses view requires authentication ### Watch out - A public form URL with no cap is an open door: one script fills it overnight, your notification inbox has nine thousand messages, your storage bill has nine thousand files, and the real responses are somewhere in the middle. Cap responses per form, rate limit per address, and close the form at the cap rather than accepting quietly.
# Operations · Youform ## Backup The database and the object storage together. Restoring one without the other gives you responses referencing files that are gone, or files nobody can attribute. Take both on the same schedule. ## Restore Restore both, then run the outbox worker and confirm undelivered rows deliver rather than duplicating. Re-import an export bundle into an empty instance as the actual proof the backup is usable. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Outbox depth, dead lettered rows, forms near their response cap, and the age of the oldest undelivered row. A silent queue is the failure that looks like nothing is wrong. ## Incident checklist A missing response means the submit transaction failed, and the request log will say so. A missing webhook means the outbox is stuck, and the dead letter view names the row and the last error. Neither is diagnosed by guessing. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A published version is confirmed immutable - [ ] A direct post with an invalid value is confirmed rejected server-side - [ ] A failing webhook receiver is confirmed to cost no responses - [ ] A file over the platform body limit is confirmed to upload - [ ] An export bundle is confirmed to re-import intact ## Launch constraint Do not market omitted Youform capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Forms, versions, responses and the outbox. The whole product. DATABASE_PATH=./data/forms.db # Required. Where presigned uploads go. Never through the API route. STORAGE_ENDPOINT=https://s3.eu-west-1.amazonaws.com # Required · secret. Used only to sign upload URLs and to delete on request. STORAGE_ACCESS_KEY=AKIA...redacted # Required · secret. Never reaches the browser. Presigned URLs do. STORAGE_SECRET_KEY=redacted # Required. Enforced in the storage policy as well as the page. UPLOAD_MAX_BYTES=26214400 # Required · secret. Notification email, sent from the outbox worker. EMAIL_API_KEY=re_...redacted # Required. After this a row is dead lettered and shown, not retried forever. OUTBOX_MAX_ATTEMPTS=12 # Required. Per address, per form. The thing that stops the overnight script. RATE_LIMIT_PER_HOUR=30
$ choose a build depth, inspect the files, then open the complete pack in your agent
Youform: Customers pay for dependable collection, spam control, notifications, integrations, and confidence that responses will not disappear.
xspam and abuse defense at scale
xlarge integration and template catalog
xcompliance controls and guaranteed delivery
xenterprise workflow logic
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Youform →· no votes, no pay-to-list · just what's real
Youform pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Unlimited forms, responses and questions; 1 user; 10 MB maximum file upload. |
| pro | $29/workspace | $20/workspace | Unlimited forms, responses and questions; 3 team members. |
| business | $89/workspace | $60/workspace | Unlimited forms, responses and questions; 5 included team members. |
free tierUnlimited forms, responses and questions; 1 user; 10 MB maximum file upload.
billingMonthly + annual; annual pricing is displayed as approximately 33% lower and is billed yearly.
hidden costsBusiness seats above the included 5 cost $10/month each; phone OTP uses the customer's own Twilio account and therefore creates separate Twilio usage charges; uploads are subject to fair-use controls.
verified 2026-08-12 · source ↗
Vibecode Youform
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Youform replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Youform cost?
Youform costs about $29/month (Pro, checked 2026-07-31), which is $348 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Youform?
Honestly: spam and abuse defense at scale; large integration and template catalog; compliance controls and guaranteed delivery; enterprise workflow logic. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Youform?
Yes: HeyForm (Typeform's manners, minus the monthly rent) OpnForm (Open-source forms with unlimited respondents; the free self-hosted builder team stops at two) Typebot (Conversational forms with deep branching; self-hosting is free under a fair-source licence) All 4 curated free alternatives are at vibecodeit.com/youform/alternatives. The prompt is for when you want it exactly your way.