
Create Web Forms: HTML, React, & No Backend Code
You've got a static site ready to ship, the design is done, and then the boring part shows up. The contact form looks finished, but it doesn't go anywhere.
That's the point where a lot of JAMstack projects stop being “just HTML” and start needing an opinionated form workflow. If you want to create web forms without building and maintaining backend plumbing, you need to be deliberate about markup, submission handling, security, and where the data lands after submit.
Why Your Static Site Needs a Form Backend
A static site can render a form just fine. HTML has handled that since the first generation of true web forms arrived in 1995, when the web got real <form>, <input>, and <textarea> elements for sending user data to servers for processing, as outlined in this history of web forms.
That historical detail matters because the form model hasn't changed at its core. A browser collects data. A server receives it. Static sites remove the server you would normally control, but they don't remove the need for somewhere to send submissions.
What breaks on a static site
The frontend part is easy. The missing pieces are the operational ones:
- Submission processing: Something has to accept a POST request and decide whether it's valid.
- Delivery: You need email notifications, storage, redirects, or downstream automation.
- Security checks: Spam filtering and validation still need to happen after the browser sends data.
- Compliance tasks: You may need consent tracking, deletion, or export without direct database access.
That's why a form backend service exists. It acts as the receiver for your form submissions so the static site can stay static.
Practical rule: If your site has no server runtime that you control, your form still needs a backend. It's just hosted somewhere else.
The architecture that usually makes sense
For most static projects, the cleanest architecture looks like this:
- The browser submits form data.
- A hosted endpoint receives and validates it.
- The service sends notifications or forwards the payload.
- The user gets a success or error state without you building a custom backend.
If you want a few examples of that pattern beyond forms, this roundup of backend as a service examples is useful because it shows the broader model JAMstack teams already rely on.
The trade-off is straightforward. You give up some control compared with writing your own API, but you avoid maintaining a server, database, queues, mail delivery, and abuse prevention for what is often just a contact form.
The Foundation A Clean and Accessible HTML Form
Most form problems start before JavaScript. They start in the markup.
Content about how to create web forms usually obsesses over spacing, placeholders, and button styles, while under-serving the harder issue of accessibility and compliance in static or JAMstack setups where there's no backend logic to inject labels or return structured validation errors, as noted in Oneupweb's form design discussion.

Start with semantic HTML
Here's a clean baseline you can paste into a static page right now:
<form
action="https://api.example-form-backend.com/submit"
method="POST"
enctype="multipart/form-data"
>
<div>
<label for="name">Full name</label>
<input
id="name"
name="name"
type="text"
autocomplete="name"
required
/>
</div>
<div>
<label for="email">Email address</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
/>
</div>
<div>
<label for="company">Company</label>
<input
id="company"
name="company"
type="text"
autocomplete="organization"
/>
</div>
<div>
<label for="message">Project details</label>
<textarea
id="message"
name="message"
rows="6"
required
></textarea>
</div>
<div>
<input id="consent" name="consent" type="checkbox" required />
<label for="consent">I agree to the storage of my submission.</label>
</div>
<button type="submit">Send message</button>
</form>This isn't fancy. That's why it works.
What each attribute is doing
A few pieces are essential:
labelwithfor: Theforvalue must match the inputid. That gives you a bigger click target and a usable relationship for assistive tech.name: This becomes the key in the submitted payload. If there's noname, the field value won't be submitted.method="POST": Form submissions that contain user input should generally be POST requests.action: This is the endpoint that will receive the submission.required: Browser validation is useful for quick feedback, but it's only the first layer.enctype="multipart/form-data": Add this when the form may include file uploads.
A placeholder is not a label. Once a user starts typing, the placeholder disappears and the field loses context.
A better production starter
For a production form, add a status region and a hidden honeypot field. The status region helps screen readers and gives you a place for JavaScript-driven feedback later.
<form
action="https://api.example-form-backend.com/submit"
method="POST"
enctype="multipart/form-data"
novalidate
>
<div>
<label for="full-name">Full name</label>
<input id="full-name" name="name" type="text" autocomplete="name" required />
</div>
<div>
<label for="email-address">Email address</label>
<input id="email-address" name="email" type="email" autocomplete="email" required />
</div>
<div>
<label for="message-body">Message</label>
<textarea id="message-body" name="message" rows="5" required></textarea>
</div>
<div hidden aria-hidden="true">
<label for="website">Website</label>
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off" />
</div>
<p id="form-status" aria-live="polite"></p>
<button type="submit">Submit</button>
</form>The website field is a classic honeypot. Real users won't fill it. Many bots will.
If you want a simpler HTML-only walkthrough before wiring frameworks on top, this HTML form setup guide is a useful reference.
Integrating Forms in Modern JavaScript Frameworks
Plain HTML is enough for many sites, but developers want better control over loading states, inline errors, conditional UI, and custom redirects. That's where framework-driven submissions make sense.
The pattern is the same in React, Next.js, and Vue. Keep the HTML accessible, capture submit, POST to an endpoint, and reflect the result in the UI.
React with hooks
This is the shape I use most often for a small React form. It keeps state local, disables repeat submissions, and surfaces a status message.
import { useState } from "react";
export default function ContactForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
const [status, setStatus] = useState("idle");
const [message, setMessage] = useState("");
function handleChange(event) {
const { name, value } = event.target;
setFormData((prev) => ({ ...prev, [name]: value }));
}
async function handleSubmit(event) {
event.preventDefault();
setStatus("submitting");
setMessage("");
try {
const response = await fetch("https://api.example-form-backend.com/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error("Request failed");
}
setStatus("success");
setMessage("Thanks. Your message has been sent.");
setFormData({ name: "", email: "", message: "" });
} catch (error) {
setStatus("error");
setMessage("Something went wrong. Please try again.");
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Full name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
/>
</div>
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Sending..." : "Send"}
</button>
<p aria-live="polite">{message}</p>
</form>
);
}The main pitfall here is over-building the client side and forgetting that final validation has to happen at the receiving endpoint.
For a React-specific implementation reference, this React form handling article is worth keeping open while you wire things up.
Next.js client component
In Next.js, use a Client Component when you want browser-side state and submit handling. The form logic is nearly identical to React, but it helps to mark the component explicitly.
"use client";
import { useState } from "react";
export default function ContactForm() {
const [pending, setPending] = useState(false);
const [feedback, setFeedback] = useState("");
async function handleSubmit(event) {
event.preventDefault();
setPending(true);
setFeedback("");
const formData = new FormData(event.currentTarget);
try {
const response = await fetch("https://api.example-form-backend.com/submit", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Submission failed");
}
event.currentTarget.reset();
setFeedback("Message sent.");
} catch (error) {
setFeedback("Submission failed.");
} finally {
setPending(false);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="attachment">Attachment</label>
<input id="attachment" name="attachment" type="file" />
</div>
<button type="submit" disabled={pending}>
{pending ? "Sending..." : "Submit"}
</button>
<p aria-live="polite">{feedback}</p>
</form>
);
}Use FormData when files are involved. Don't set the Content-Type header manually in that case. Let the browser build the multipart boundary.
Vue with v-model
Vue stays concise. v-model is convenient, but the same rule applies. Keep the HTML correct first, then make the state layer pleasant.
<template>
<form @submit.prevent="submitForm">
<div>
<label for="name">Full name</label>
<input id="name" v-model="form.name" name="name" type="text" required />
</div>
<div>
<label for="email">Email address</label>
<input id="email" v-model="form.email" name="email" type="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" required></textarea>
</div>
<button type="submit" :disabled="pending">
{{ pending ? "Sending..." : "Send" }}
</button>
<p aria-live="polite">{{ feedback }}</p>
</form>
</template>
<script setup>
import { reactive, ref } from "vue";
const form = reactive({
name: "",
email: "",
message: "",
});
const pending = ref(false);
const feedback = ref("");
async function submitForm() {
pending.value = true;
feedback.value = "";
try {
const response = await fetch("https://api.example-form-backend.com/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Submission failed");
}
feedback.value = "Message sent.";
form.name = "";
form.email = "";
form.message = "";
} catch (error) {
feedback.value = "Something went wrong.";
} finally {
pending.value = false;
}
}
</script>What works and what usually doesn't
A few patterns consistently hold up in production:
| Approach | Works well when | Friction you should expect |
|---|---|---|
| Native form POST | You want the simplest implementation and can rely on redirects | Less control over inline UX |
fetch with JSON |
You want custom success and error UI | You need endpoint support for JSON payloads |
fetch with FormData |
You need file uploads or mixed field types | CORS and multipart handling need to be right |
Keep browser validation for convenience. Don't confuse it with actual protection.
What usually fails is treating the frontend as the enforcement layer. If the endpoint accepts bad payloads, client-side niceties won't save you.
Configuring Your Form Backend for Security
A static form is only as safe as the endpoint receiving it. Clean HTML, framework state, and nice validation help the user experience, but the primary control point is the service that accepts the submission, checks it, and decides what happens next.

Set the endpoint and post-submit behavior
Hosted form backends usually need the same four decisions:
- Action URL: where the browser sends the form
- Form identifier or access key: how the provider maps the request to the right inbox or workflow
- Success handling: redirect to a thank-you page or stay on the current page and render feedback
- Notification routing: email alerts, dashboard storage, webhooks, or another downstream step
That configuration decides more than delivery. It affects UX, spam handling, and how much frontend code you need to maintain.
Static Forms follows this general hosted-backend pattern. You post to its API, include an access key, and choose whether the submission ends in a dashboard, email notification, redirect, or another connected workflow.
A minimal HTML setup looks like this:
<form action="https://api.staticforms.dev/submit" method="POST" enctype="multipart/form-data">
<input type="hidden" name="accessKey" value="YOUR_ACCESS_KEY" />
<input type="hidden" name="redirectTo" value="https://yoursite.com/thanks" />
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
</div>
<button type="submit">Send</button>
</form>Redirects are quick to ship and easy to reason about. Inline success states feel better in apps, but they push more logic into the frontend and make error handling your responsibility.
Add spam controls before traffic arrives
Spam filters are easier to set up early than to retrofit after a form gets abused. On a static site, that matters because there is no custom server layer sitting in front of the provider to catch bad traffic.
Common options on hosted backends include:
- Honeypot fields: low friction and a good default first layer
- reCAPTCHA v2 or v3: useful if you already use Google services and accept the extra client-side dependency
- Cloudflare Turnstile: a lighter alternative for many public forms
- Altcha: a good fit if you want bot resistance without a traditional CAPTCHA flow
Static Forms supports honeypots and several challenge-based options, as noted earlier. The right choice depends on who fills out the form. A basic contact page can often start with a honeypot and rate limiting. A form that attracts spam, such as quote requests or lead-gen pages, usually needs stronger checks.
Lock down browser policy
The browser should only be allowed to submit to endpoints you trust. That is where Content Security Policy helps.
Implementing a strict Content Security Policy is a critical step for static forms because CSP restricts where scripts load from and where forms can submit, helping prevent XSS and unauthorized content injection, as described in Kiteworks' CSP guidance for forms.
For hosted form submission, the two directives that matter most are connect-src and form-action. If those are too broad, any injected script or modified markup gets more room to exfiltrate data.
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://api.example-form-backend.com;
form-action 'self' https://api.example-form-backend.com;
frame-ancestors 'none';
base-uri 'self';Start strict, then open only what the form needs. If you add Turnstile, reCAPTCHA, analytics, or embedded success flows later, update CSP deliberately instead of widening it across the board.
Server-side validation still has to reject bad input, even if your frontend checks types, lengths, and required fields.
Treat email delivery as part of the build
If the provider sends notification emails or auto-replies from your domain, set up SPF, DKIM, and DMARC based on that provider's documentation. Otherwise, messages may land in spam or fail alignment checks, which turns a working form into a support issue.
Make these choices up front:
- Provider-managed delivery or custom-domain sending
- Sender name and reply-to behavior
- Auto-response messages to submitters
- Whether notifications go to one inbox, a group alias, or another tool
This is infrastructure work, not polish. If a prospect submits a form and the notification never arrives, the frontend did its job and the workflow still failed.
Advanced Workflows File Uploads and Integrations
Basic contact forms are easy. The hard stuff starts when the form needs to carry files, trigger workflows, and avoid exposing implementation details in the browser.
A common unanswered question is how to handle file uploads and spam protection in static forms without exposing an API key or breaking CORS, and that gap shows up often in static-site projects, as discussed in FormAssembly's article on web form pitfalls.

File uploads without writing backend code
On a static site, file uploads are awkward because somebody still has to receive the multipart request, validate the file, store it, and associate it with the rest of the submission.
Hosted form backends solve that by terminating the upload on their side. The browser sends one multipart request, and the service handles validation, storage, and delivery.
Static Forms supports file uploads up to 5MB per submission in its documented setup for secure form handling, including anti-spam tooling, as shown in its security docs.
A minimal HTML example looks like this:
<form action="https://api.example-form-backend.com/submit" method="POST" enctype="multipart/form-data">
<div>
<label for="name">Your name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="resume">Upload file</label>
<input id="resume" name="resume" type="file" />
</div>
<button type="submit">Send</button>
</form>Avoid the common upload mistakes
The mistakes are repetitive:
- Embedding secrets carelessly: If a workflow needs a form identifier, use the provider's documented public submission pattern. Don't improvise with private admin credentials in frontend code.
- Forcing JSON for file uploads: Files belong in
FormDataand multipart requests. - Ignoring origin rules: CORS failures usually mean the endpoint or submission method doesn't match what the service expects.
- Skipping user-facing constraints: Tell users which file types you accept and whether the form has upload limits.
A React example for file uploads should look like this:
async function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const response = await fetch("https://api.example-form-backend.com/submit", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Upload failed");
}
}That's the correct shape. No manual multipart header. No base64 detour.
If the browser sends files directly, use multipart form submission and let the receiving service parse it. Trying to outsmart that usually creates the bug.
Webhooks and downstream automations
Once the submission reaches a form backend, webhooks are usually the cleanest extension point. A webhook sends the form payload as a JSON POST to a URL you control or to an automation platform.
That lets you route one submission into multiple systems:
| Destination | Good fit | What you gain |
|---|---|---|
| Google Sheets | Lightweight lead or intake tracking | Fast visibility for non-technical teammates |
| Slack | Team notifications | Instant awareness in an existing channel |
| Notion | Structured intake databases | Internal documentation and triage |
| Zapier, Make, n8n | General automation | Connect to CRMs, ticketing, and custom flows |
A generic webhook receiver might expect payloads in a shape like this:
{
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Interested in a project quote"
}If you control the receiving app, normalize field names early. full_name, name, and contactName become annoying faster than you'd think.
GDPR and operational cleanup
GDPR isn't just a checkbox field. If you store submissions in a hosted dashboard, you need a practical way to export or delete data when a client asks.
Look for form backends that support:
- Data export: So you can hand over submitted data when requested.
- Deletion controls: So you can remove records without direct database access.
- Consent fields: So you can record whether the submitter agreed to storage or follow-up.
- Retention workflows: So old submissions don't sit around forever by accident.
That operational side matters more than the form UI once a site is live.
Choosing Your Form Handling Strategy
There isn't one correct way to create web forms. There's a right fit for the project you're shipping.

The trade-offs in plain terms
Here's the comparison often needed:
| Strategy | Choose it when | Costs you |
|---|---|---|
| Hosted form backend | You want working forms quickly with minimal maintenance | Less flexibility than owning the full stack |
| Serverless function | You need custom logic and already work comfortably in your hosting platform | More setup, more validation work, more operational responsibility |
| Custom backend | The form is tightly coupled to internal systems or complex business rules | Ongoing maintenance, security ownership, and email delivery work |
Hosted services such as Formspree, Getform, Basin, Web3Forms, and Static Forms all sit in the first category. The core advantage is obvious. You don't have to maintain the receiving infrastructure yourself.
Serverless functions on Vercel or Netlify are the middle ground. They're a good option when you need custom processing, but you still want to avoid a full persistent backend. The downside is that spam filtering, validation, storage, notifications, retries, and compliance tasks become your problem again.
A custom backend only makes sense when the form is no longer “just a form.” If submissions need deep integration with internal systems, custom auth, or complex workflows that don't fit webhook patterns, owning the stack can be justified.
A practical recommendation
For brochure sites, startup landing pages, agency client sites, documentation portals, and lightweight SaaS intake forms, a hosted backend is usually the most efficient choice.
For product workflows where form submission is part of application logic, serverless or custom code often makes more sense.
If the managed route fits what you're building, start small, test with a real submission flow, and keep the HTML accessible before you add framework polish.
If you want the hosted route, Static Forms is one option to evaluate. It's designed for static and JAMstack sites, accepts submissions at a hosted endpoint, supports spam controls including reCAPTCHA v2/v3, Cloudflare Turnstile, Altcha, and honeypots, handles file uploads up to 5MB, and can route submissions to email, webhooks, Google Sheets, Slack, Notion, and other destinations without writing server code.
Related Articles
Create an HTML Form: Markup, Backend, & Spam Protection
Learn to create an HTML form for your site. Master markup, validation, serverless backend for submissions, file uploads, & spam protection.
Build a User Feedback Form for Your Static Site
A step-by-step guide for developers to design and implement a user feedback form on any static site. Includes code examples, UX tips, and integrations.
How to Create Website Forms: An End-to-End Developer Guide
Learn how to create website forms that work. This guide covers HTML, JS validation, serverless backends, framework examples, spam protection, and GDPR.