Collect form submissions with zero third-party dependencies. Define forms in config, render at build time, receive submissions in the admin inbox.
How it works
The CMS admin itself is the form backend. Static sites POST cross-origin directly to the admin API. Submissions are stored as JSON files. The admin has an inbox with unread badges. No Formspree, no Netlify Forms, no vendor lock-in.
Static site → POST /api/forms/contact → CMS admin
↓
_data/submissions/contact/*.json
↓
Email + webhook notification
↓
Admin inbox with badgeDefine a form
Add a forms array to your cms.config.ts:
import { defineConfig } from '@webhouse/cms';
export default defineConfig({
collections: [/* ... */],
forms: [
{
name: 'contact',
label: 'Contact Form',
fields: [
{ name: 'name', type: 'text', label: 'Name', required: true },
{ name: 'email', type: 'email', label: 'Email', required: true },
{ name: 'company', type: 'text', label: 'Company', placeholder: 'Optional' },
{ name: 'message', type: 'textarea', label: 'Message', required: true },
],
successMessage: 'Thanks! We will get back to you within 24 hours.',
notifications: {
email: ['hello@example.com'],
webhook: 'https://hooks.slack.com/services/T.../B.../xxx',
},
spam: {
honeypot: true, // default
rateLimit: 5, // max per IP per hour, default
},
},
],
});Field types
| Type | HTML element | Notes |
|---|---|---|
text | <input type="text"> | General text |
email | <input type="email"> | Browser validates format |
textarea | <textarea> | Multi-line, 4 rows default |
select | <select> | Requires options: [{ label, value }] |
checkbox | <input type="checkbox"> | Boolean |
number | <input type="number"> | Numeric |
phone | <input type="tel"> | Phone keyboard on mobile |
url | <input type="url"> | URL format |
date | <input type="date"> | Native date picker |
hidden | <input type="hidden"> | Use defaultValue to set |
All fields support:
required— browser + server validationplaceholder— hint textvalidation.pattern— regex (e.g."^[A-Z]"for must-start-with-capital)validation.minLength/maxLength
Three ways to use forms
1. Build output (zero effort)
cms build generates forms/<name>/index.html in your output directory. The page contains a styled, accessible <form> with async submit, honeypot, and a JS-free fallback.
Link to it from your site: <a href="/forms/contact/">Contact us</a>.
Set CMS_ADMIN_URL to your production admin URL so the form action points at the right place:
CMS_ADMIN_URL=https://cms.example.com cms build2. Embeddable widget (one script tag)
Drop this on any page — even pages not built by the CMS:
<script src="https://cms.example.com/api/forms/contact/widget.js"></script>
<div id="webhouse-form-contact"></div>The script fetches the form schema, renders a styled form, handles submission via fetch, and shows success/error inline. ~4 KB, zero dependencies, includes honeypot.
3. Custom HTML (full control)
Build your own <form> and POST to the API:
<form action="https://cms.example.com/api/forms/contact" method="POST">
<input name="name" required>
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<!-- Honeypot: bots fill this, humans don't see it -->
<div style="position:absolute;left:-9999px">
<input name="_hp_email" tabindex="-1" autocomplete="off">
</div>
<button type="submit">Send</button>
</form>The endpoint accepts both application/json and application/x-www-form-urlencoded. For JSON:
fetch('https://cms.example.com/api/forms/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Jane', email: 'jane@x.com', message: 'Hi' }),
});Spam protection
Two layers enabled by default:
- Honeypot — a hidden field (
_hp_email) that's invisible to humans but bots auto-fill. If the field has any value, the submission is silently accepted (returns 200) but never stored — so bots think they succeeded.
- IP rate limiting — max 5 submissions per IP per hour (configurable via
spam.rateLimit). Returns 429 when exceeded. IP addresses are hashed (SHA-256, truncated to 8 hex chars) before any storage — GDPR-friendly.
Optional third layer: Cloudflare Turnstile. Set TURNSTILE_SECRET_KEY in your admin's .env, add the Turnstile script to your form page, and include the cf-turnstile-response field in the POST body. The endpoint validates the token server-side.
Admin inbox
Open Forms in the sidebar (below Interactives). Each form shows its unread count.
Click a form to open the inbox:
- Status dots: blue = new, grey = read, faded = archived
- Filter tabs: All / New / Read / Archived
- Detail panel: click any submission to see all fields
- Actions: Archive, Delete (with inline confirm)
- CSV export: download button in the action bar
The sidebar badge shows the total unread count across all forms.
Notifications
Configure per form in cms.config.ts:
notifications: {
email: ['hello@example.com', 'sales@example.com'],
webhook: 'https://hooks.slack.com/services/...',
}Email: uses Resend (RESEND_API_KEY env var) or falls back to console log. From address: CMS_EMAIL_FROM or forms@webhouse.app.
Webhook: POSTs the full submission as JSON to the configured URL. Works with Slack, Discord, Zapier, Make, or any custom endpoint.
F35 event: every submission also fires a form.submitted event through the site's webhook system, so existing Discord/Slack integrations receive it automatically.
CORS
The public /api/forms/* endpoints accept cross-origin requests from:
- The site's
previewSiteUrl(from Site Settings) localhost:3000,localhost:3009,localhost:3011in development
Production sites should set their previewSiteUrl in Site Settings to match the domain the form lives on.
API reference
Public
| Method | Path | Description |
|---|---|---|
| POST | /api/forms/[name] | Submit form |
| GET | /api/forms/[name]/schema | Form field definitions |
| GET | /api/forms/[name]/widget.js | Embeddable script |
Admin (authenticated)
| Method | Path | Description |
|---|---|---|
| GET | /api/admin/forms | List forms + unread counts |
| GET | /api/admin/forms/[name]/submissions | List submissions |
| GET | /api/admin/forms/[name]/submissions/[id] | Single submission |
| PATCH | /api/admin/forms/[name]/submissions/[id] | Update status |
| DELETE | /api/admin/forms/[name]/submissions/[id] | Delete |
| GET | /api/admin/forms/[name]/export | CSV download |
Chat integration
The CMS chat assistant can list forms and submissions:
- "Show me all forms" →
list_forms - "Any new contact submissions?" →
list_form_submissions
Data persistence
Submissions live in _data/submissions/<form>/ — the same _data/ directory that stores users, team files, and backup metadata. This directory is gitignored by default, which means:
- Submissions survive server restarts and redeployments (the directory persists on the volume).
- Submissions are not included in git-based backups. Use F27 Backup & Restore with a cloud target (S3, pCloud) to include
_data/in automated backups. - F122 Beam teleports content but not
_data/. Export submissions via CSV before beaming if you need to transfer them. - On Docker deploys (Fly.io), mount a persistent volume at the site root so
_data/survives container recreation.
If you need submissions in version control (e.g. for audit), add !_data/submissions/ to your .gitignore.
See also
- Webhooks — receive form.submitted events in Discord, Slack, or custom endpoints
- Deployment — ensure
CMS_ADMIN_URLis set for production form action URLs