Skip to main content

Headless Form Backend Template

A Form Backend for
Ordinary HTML Forms

Point <form action="..."> at your own endpoint and the submissions land in your own database. No JavaScript required on the page, and no server of your own to run.

npm install -g codehooks
coho create myforms --template form-backend
cd myforms && npm install && coho deploy
# Your form endpoint is live

No credit card required. Free tier for development. View the source →

Change One Attribute and You're Collecting

Each form you create gets a uuid, and that uuid is its endpoint. Set the endpoint as the form's action and you're done. A form with no field schema accepts whatever the browser sends, so an existing form keeps working exactly as it did.

The response depends on how the request arrived. A browser form post gets a 302 to your redirect URL (or to a hosted thank-you page if you haven't set one). A request sent with content-type: application/json gets JSON back instead.

Add enctype="multipart/form-data" and a file input, and uploads are stored with the submission.

A contact form, no JavaScript anywhere:

<form method="POST"
action="https://your-app.codehooks.io/f/YOUR-FORM-UUID">
<input name="name" placeholder="Your name" required>
<input name="email" type="email" required>
<textarea name="message"></textarea>
<!-- bots fill this in, you never see it -->
<input type="text" name="_gotcha" hidden>
<button type="submit">Send</button>
</form>

The same endpoint, called with JSON:

curl -X POST https://your-app.codehooks.io/f/YOUR-FORM-UUID \
-H 'content-type: application/json' \
-d '{"name":"Ada","email":"[email protected]"}'
# {"ok":true,"id":"...","submissionId":"..."}

Try It Before You Deploy Anything

There's a working client at demo.formbackend.dev that posts cross-origin to a real backend at api.formbackend.dev. It prints the request it's about to send and the response it got back, so you can see the JSON reply and the redirect behaviour side by side. The disclosure at the bottom of the page carries the plain HTML form that does the same thing with no JavaScript.

The demo ships inside the template, at form-backend/example/.

What the Form Backend Does

Everything below runs on a live deployment today

One Endpoint Per Form

Every form gets its own POST /f/:formId address. Set it as the action on an ordinary HTML form and submissions start arriving.

Works Without JavaScript

A plain browser form post gets a 302 to your redirect URL or a hosted thank-you page. Send application/json instead and you get {"ok":true,"id":"..."} back.

JSON, Urlencoded and File Uploads

The same endpoint accepts application/json, application/x-www-form-urlencoded and multipart/form-data. A 2 MB upload round-trips byte for byte (verified by md5).

Optional Typed Validation

A form with no field schema accepts anything, so an existing form keeps working. Define fields and the server enforces types, required, min/max and select options.

Server-Side Domain Allowlist

The Origin or Referer hostname is checked before anything is stored, and matching is exact. Unlike CORS, this rejects the request rather than just hiding the reply.

Inbox API and CSV Export

List, search, filter, star, annotate and delete submissions over a JWT-protected API. Export to CSV with spreadsheet formula injection neutralised.

The Allowlist Is a Server-Side Check

Put hostnames in a form's allowedDomains and the server reads the Origin header (falling back to Referer) and compares the hostname against that list before it stores anything. A request from somewhere else gets a 403 and nothing is written.

That's a different thing from CORS, which only decides whether a browser lets script read the response. CORS headers are set here too, but they aren't what protects the form.

Matching is exact on the full hostname, so near-misses don't slip through. An empty list accepts any origin, which suits a public contact form and stops suiting you the moment you care where submissions come from.

Allowlist: demo.example.com
demo.example.com
on the allowlist
200
evil.example
not on the list at all
403
evil-example.com
a suffix match would have let this in
403
notdemo.example.com
subdomains are not implied
403

Behaviour verified against a live deployment.

An Inbox You Query, and a CSV You Can Open

Submissions are documents in your Codehooks database. The admin API pages through them, searches across the submitted values, filters by status and date range, and lets you mark a submission read or archived, star it, attach an internal note, or delete it (which removes its uploaded files as well).

Logging in at /admin/login sets a JWT in an HttpOnly, Secure, SameSite=Strict cookie, and every admin route requires it. Uploaded files are served through an authenticated route only, never a public one.

The CSV export escapes values that a spreadsheet would otherwise treat as formulas, so a submitted string starting with = stays text when someone opens the file in Excel.

Log in, read the inbox, export it:

API=https://your-app.codehooks.io
# password login, JWT lands in a cookie jar
curl -c cookies -X POST "$API/admin/login" \
-H 'content-type: application/json' \
-d '{"password":"your-admin-password"}'
# search the inbox
curl -b cookies \
"$API/admin/api/forms/$FORM/submissions?search=ada&status=new"
# everything, as CSV
curl -b cookies -O \
"$API/admin/api/forms/$FORM/export.csv"

A Self-Hosted Alternative to Hosted Form Services

Formspree, Getform, Basin and forminit all give you an endpoint to post to. This template gives you the endpoint and the database behind it.

CapabilitySelf-HostedHosted SaaS
Submissions stored in your own database
Full source code you can change
Flat backend pricing, not per submission
No row or retention limits imposed by a vendor
Works with a plain HTML form, no JavaScript
File uploads and CSV export

Forms This Is Built For

One backend, one endpoint per form

Contact Forms on a Static Site

A site on Netlify, Vercel, GitHub Pages or S3 has nowhere to POST to. Point the form here and keep the site static.

name • email • message

Applications and Uploads

Job applications, support tickets and anything else that arrives with an attachment. Files are stored with the submission and served only to an admin.

multipart • cv.pdf • screenshot.png

Surveys and Signups

Define a field schema and the server enforces it, including select options and rating ranges. Export the answers as CSV when you want to analyse them.

rating • select • export.csv

TypeScript, 103 Tests, No Build Step

Route registration lives in index.ts, and the decisions that are worth testing live in small modules under lib/: body parsing, multipart, validation, the allowlist and redirect rules, search, CSV. The tests run straight on the TypeScript with node --test, so there's nothing to compile before you can run them.

It's a small enough codebase for a coding agent to read in one go, which makes it a reasonable starting point when your forms need something the template doesn't do.

myforms/
├── index.ts # routes only
├── lib/
│ ├── body.ts # json / urlencoded / multipart
│ ├── multipart.ts # streaming parser
│ ├── validation.ts # typed fields, strict mode
│ ├── security.ts # allowlist, safe redirect
│ ├── files.ts # filestore uploads
│ ├── search.ts # inbox search + paging
│ └── csv.ts # export, formula-safe
├── test/ # 103 tests, node --test
└── example/ # the live demo client
Not built yet

Coming Next

This is the first release. The items below are planned and are not in the template today, so don't deploy it expecting them.

  • Email notifications on new submissions
  • Autoresponder to the person who submitted
  • Outgoing webhooks and Slack or Discord notifications
  • Spam scoring and honeypot enforcement
  • AI triage of incoming submissions
  • A visual admin dashboard on top of the inbox API

Until then, submissions are stored and read through the API, and you own the source if you want any of this sooner.

Deploy Your Own Form Backend

One command to deploy, one attribute to change on your form, and the submissions are yours to query.

Form Backend FAQ

Common questions about the self-hosted form backend template

What is a form backend?
A form backend receives the submissions from your HTML forms so you don't have to run a server for them. You point
at an endpoint, and the service stores what people send. This template deploys that endpoint to your own Codehooks backend, so the submissions land in a database you control.
Does it work without JavaScript?
Yes. A normal browser form post is answered with a 302 to the redirect URL you configured, or to a hosted thank-you page if you haven't set one. Nothing on the page needs to run, and the form works with JavaScript disabled. If you'd rather submit with fetch, send content-type: application/json and the same endpoint replies with {"ok":true,"id":"..."} instead.
Can I use it with my existing form?
Usually yes, without editing the fields. A form with no field schema accepts whatever you send, so you only have to change the action attribute. When you want the server to enforce types you can add a schema later, and strict mode on top of that rejects fields you didn't define.
How do I stop other sites from posting to my form?
Add hostnames to the form's allowedDomains and the server checks the Origin (or Referer) header against that list before anything is stored. Matching is exact on the full hostname, so demo.example.com on the list does not admit evil-example.com or notdemo.example.com. An empty list accepts any origin, which is the right default for a public contact form and the wrong one once you care where submissions come from.
How do I stop spam?
Today you have two things: the domain allowlist described above, and a honeypot convention. A field named _gotcha is stripped from the stored data, so you can include a hidden input that only bots fill in and keep it out of your inbox. Spam scoring and automatic honeypot rejection are not built yet, and are on the list for the next release.
Where are uploaded files stored?
In the Codehooks filestore that belongs to your project, alongside the submission that carries them. Files are never exposed on a public route: they're served from /admin/api/submissions/:id/files/:fileId, which requires the admin session cookie, and are sent with content-disposition: attachment and x-content-type-options: nosniff. Deleting a submission deletes its files too.
Can I export my data?
Yes. GET /admin/api/forms/:formId/export.csv returns every submission for a form as CSV. Submitted values are neutralised first, so a value like =cmd|... can't execute as a formula when the file is opened in Excel. The data is also plain documents in your own Codehooks database, so you can query it directly.
How is this different from Formspree or Getform?
Hosted form services run the endpoint for you and keep the submissions on their side, usually with a monthly submission allowance. This template gives you the same shape of endpoint while the data sits in your own database and the source is yours to change. You pay for the backend rather than per submission.
How is the admin API protected?
Password login at POST /admin/login issues a JWT in an HttpOnly, Secure, SameSite=Strict cookie, and every /admin/api/* route requires it. SameSite=Strict is doing real work here (it's what stops another page from reading your submissions with your cookie), so it's deliberately not relaxed.
Is there an admin dashboard?
Not yet. The inbox is an API today: list, search, filter by status and date, mark read or archived, star, attach notes and delete. A visual dashboard on top of it is planned, and the template ships with a working example client at demo.formbackend.dev that shows the submit side end to end.