How to Add a Contact Form to a Static HTML Site (Without a Backend)
Static sites are fantastic: fast, cheap to host, secure, and dead simple to deploy. Then a client says "can visitors email us from the site?" and suddenly you're staring at the one thing a static site can't do on its own: receive a form submission. The good news is you don't need a backend or server to do this. By the end of this guide you'll have a fully working, accessible, spam-resistant contact form you can drop into plain HTML, React, Next.js, Vue, Astro, or Svelte, with complete copy-paste code, not placeholders.
Let's start by being honest about your options. Full disclosure I work with Formpaste, one of the form-backend services covered below , so I'll use it in the hands-on examples. I've kept the comparison section genuinely even-handed. The techniques here work with any provider, and I'll tell you where each one fits.
The options for handling forms without a backend
There are really four common approaches, and they're not equal.
mailto: links. Zero setup.
<a href="mailto:y**@example.com">opens the visitor's email client. But it's a rough experience: it depends on a configured desktop mail app, publishes your address for scrapers to harvest, and collects nothing structured. Fine for a personal landing page, not for a real contact form.Google Forms embed. Free and no code, but the iframe looks nothing like your site, works against your design and performance, and the UX is clearly "this is a Google Form." Fine for a hobby project, weak for anything that needs to look professional.
Host-native form handling. Netlify Forms and a few other hosts offer built-in form capture. Genuinely nice, if you're on that host. The moment you move to GitHub Pages, Cloudflare Pages, or your own S3 bucket, it's gone. You're locked in.
A form-backend API. You point your
<form>at an endpoint; the service receives submissions, filters spam, and emails you (or fires a webhook). Host-agnostic, works with any framework, and keeps your markup 100% yours. This is the approach that works everywhere, so it's what we'll build.
Step 1: The HTML form (accessible by default)
Here's a complete, accessible form. Note the real <label> elements and the for / id pairing, which is the part most tutorials skip.
<form action= "https://api.formpaste.com/submit" method= "POST" id= "contact-form" >
<!-- Your access key from the dashboard -->
<input type= "hidden" name= "access_key" value= "YOUR_ACCESS_KEY_HERE" />
<div>
<label for= "name" > Name </label>
<input type= "text" id= "name" name= "name" autocomplete= "name" required />
</div>
<div>
<label for= "email" > Email </label>
<input type= "email" id= "email" name= "email" autocomplete= "email" required />
</div>
<div>
<label for= "message" > Message </label>
<textarea id= "message" name= "message" rows= "5" required ></textarea>
</div>
<button type= "submit" > Send message </button>
</form>
That's a working form already. With no JavaScript at all, a submission POSTs to the endpoint and lands in your inbox. Progressive enhancement done right: it works before a single line of JS runs. By default the endpoint answers with JSON. Without JavaScript on the page the browser will simply display that JSON response, which is not the experience you want, so see Step 4 for how to send the visitor to a thank-you page instead.
Step 2: Enhance it with JavaScript (proper loading and error states)
The plain form causes a full page navigation. To keep users on the page with inline feedback, enhance it. Notice this actually selects the form by its id , handles the network failure case, and gives real status feedback, which are the details that separate a working form from a demo.
<form action= "https://api.formpaste.com/submit" method= "POST" id= "contact-form" >
<input type= "hidden" name= "access_key" value= "YOUR_ACCESS_KEY_HERE" />
<input type= "text" id= "name" name= "name" required />
<input type= "email" id= "email" name= "email" required />
<textarea id= "message" name= "message" required ></textarea>
<button type= "submit" > Send message </button>
<p id= "form-status" role= "status" aria-live= "polite" ></p>
</form>
<script>
const form = document . getElementById ( " contact-form " );
const statusEl = document . getElementById ( " form-status " );
const button = form . querySelector ( ' button[type="submit"] ' );
form . addEventListener ( " submit " , async ( e ) => {
e . preventDefault ();
button . disabled = true ;
statusEl . textContent = " Sending… " ;
try {
const response = await fetch ( form . action , {
method : " POST " ,
body : new FormData ( form ),
});
if ( response . ok ) {
statusEl . textContent = " Thanks! Your message was sent. " ;
form . reset ();
} else {
// The API returns { success: false, code, message } on failure.
const data = await response . json (). catch (() => null );
statusEl . textContent = data ?. message ?? " Something went wrong. Please try again. " ;
}
} catch ( error ) {
statusEl . textContent = " Network error. Please try again. " ;
} finally {
button . disabled = false ;
}
});
</script>
The aria-live="polite" region means screen readers announce the status change, which is a free accessibility win. A successful response is JSON shaped like this:
{
"success" : true ,
"message" : "Submission received." ,
"id" : "..."
}
Errors use the same envelope with a machine-readable code, so you can branch on data.code if you want distinct messages for rate_limited versus domain_not_allowed .
Step 3: Framework examples (complete, not placeholders)
React
import { useState } from " react " ;
export default function ContactForm () {
const [ status , setStatus ] = useState ( " idle " ); // idle | sending | success | error
async function handleSubmit ( e ) {
e . preventDefault ();
// Capture the form node now: after the first await, `currentTarget` is null.
const form = e . currentTarget ;
setStatus ( " sending " );
try {
const response = await fetch ( " https://api.formpaste.com/submit " , {
method : " POST " ,
body : new FormData ( form ),
});
setStatus ( response . ok ? " success " : " error " );
if ( response . ok ) form . reset ();
} catch {
setStatus ( " error " );
}
}
return (
< form onSubmit = { handleSubmit } >
< input type = "hidden" name = "access_key" value = "YOUR_ACCESS_KEY_HERE" />
< label htmlFor = "name" > Name </ label >
< input id = "name" type = "text" name = "name" required />
< label htmlFor = "email" > Email </ label >
< input id = "email" type = "email" name = "email" required />
< label htmlFor = "message" > Message </ label >
< textarea id = "message" name = "message" required />
< button type = "submit" disabled = { status === " sending " } >
{ status === " sending " ? " Sending… " : " Send message " }
</ button >
{ status === " success " && < p role = "status" > Thanks! Your message was sent. </ p > }
{ status === " error " && < p role = "status" > Something went wrong. Try again. </ p > }
</ form >
);
}
Next.js (App Router)
With the App Router, keep the form as a client component so you can manage submit state. You don't need a route handler at all, since the form posts directly to the API.
Worth saying plainly: the access key sits in client-side code and is visible to anyone who views source. That's by design for this kind of endpoint. The key identifies which form a submission belongs to; it isn't a secret credential, and it can't be used to read your submissions. Your protection against someone reusing it elsewhere is the domain allowlist, covered below.
" use client " ;
import { useState } from " react " ;
export default function ContactForm () {
const [ status , setStatus ] = useState ( " idle " );
async function handleSubmit ( e ) {
e . preventDefault ();
const form = e . currentTarget ; // capture before awaiting
setStatus ( " sending " );
try {
const res = await fetch ( " https://api.formpaste.com/submit " , {
method : " POST " ,
body : new FormData ( form ),
});
setStatus ( res . ok ? " success " : " error " );
if ( res . ok ) form . reset ();
} catch {
setStatus ( " error " );
}
}
return (
< form onSubmit = { handleSubmit } >
< input type = "hidden" name = "access_key" value = "YOUR_ACCESS_KEY_HERE" />
< input type = "text" name = "name" placeholder = "Name" required />
< input type = "email" name = "email" placeholder = "Email" required />
< textarea name = "message" placeholder = "Message" required />
< button type = "submit" disabled = { status === " sending " } >
{ status === " sending " ? " Sending… " : " Send message " }
</ button >
{ status === " success " && < p role = "status" > Message sent! </ p > }
{ status === " error " && < p role = "status" > Please try again. </ p > }
</ form >
);
}
Vue 3
This one posts JSON instead of FormData , just to show the endpoint accepts both. Because the values come from v-model state, the access key goes in the JSON body and there's no hidden input.
< template >
<form @ submit.prevent= "submitForm" >
<label for= "name" > Name </label>
<input id= "name" v-model= "form.name" type= "text" name= "name" required />
<label for= "email" > Email </label>
<input id= "email" v-model= "form.email" type= "email" name= "email" required />
<label for= "message" > Message </label>
<textarea id= "message" v-model= "form.message" name= "message" required />
<button type= "submit" :disabled= "status === 'sending'" >
{{ status === " sending " ? " Sending… " : " Send message " }}
</button>
<p v-if= "status === 'success'" role= "status" > Thanks! Message sent. </p>
<p v-if= "status === 'error'" role= "status" > Something went wrong. </p>
</form>
</ template >
< script setup >
import { reactive , ref } from " vue " ;
const ACCESS_KEY = " YOUR_ACCESS_KEY_HERE " ;
const form = reactive ({
name : "" ,
email : "" ,
message : ""
});
const status = ref ( " idle " );
async function submitForm () {
status . value = " sending " ;
try {
const res = await fetch ( " https://api.formpaste.com/submit " , {
method : " POST " ,
headers : { " Content-Type " : " application/json " },
body : JSON . stringify ({ ... form , access_key : ACCESS_KEY }),
});
status . value = res . ok ? " success " : " error " ;
if ( res . ok ) Object . assign ( form , { name : "" , email : "" , message : "" });
} catch {
status . value = " error " ;
}
}
</ script >
Astro
Astro ships zero JS by default, so the plain HTML form works as-is in a .astro file.
---
// src/components/ContactForm.astro
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
---
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="hidden" name="redirect" value="/thank-you" />
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">Email</label>
<input id="email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send message</button>
</form>
Because there's no JavaScript here, the redirect field matters: without it the browser would land on the raw JSON response. If you'd rather have inline feedback, drop the redirect field and add a client script:
<script>
const form = document.getElementById("contact-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch(form.action, { method: "POST", body: new FormData(form) });
form.insertAdjacentHTML(
"beforeend",
res.ok ? "<p role='status'>Message sent!</p>" : "<p role='status'>Please try again.</p>",
);
if (res.ok) form.reset();
});
</script>
Svelte
<script>
let status = " idle " ;
async function handleSubmit ( event ) {
const form = event . currentTarget ; // capture before awaiting
status = " sending " ;
try {
const res = await fetch ( " https://api.formpaste.com/submit " , {
method : " POST " ,
body : new FormData ( form ),
});
status = res . ok ? " success " : " error " ;
if ( res . ok ) form . reset ();
} catch {
status = " error " ;
}
}
</script>
<form on:submit | preventDefault= { handleSubmit } >
<input type= "hidden" name= "access_key" value= "YOUR_ACCESS_KEY_HERE" />
<input type= "text" name= "name" placeholder= "Name" required />
<input type= "email" name= "email" placeholder= "Email" required />
<textarea name= "message" placeholder= "Message" required ></textarea>
<button type= "submit" disabled= { status === " sending " } >
{ status === " sending " ? " Sending… " : " Send message " }
</button>
{ #if status === " success " }
<p role= "status" > Message sent! </p>
{ /if }
{ #if status === " error " }
<p role= "status" > Please try again. </p>
{ /if }
</form>
Step 4: Redirect vs. AJAX, pick your success flow
There are two ways to confirm a submission, and the choice is made by the request itself, not by a header.
JSON (the default). Post the form and you get
{ success, message, id }back with a 200. That's what every JavaScript example above relies on: intercept the submit,fetch, show an inline message, never leave the page.Redirect. Add a
redirectfield to the form and a successful submission answers with a 303 See Other to that URL, so the browser lands on your thank-you page. This is the no-JS path.
<input type= "hidden" name= "redirect" value= "/thank-you" />
Two rules worth knowing, because both fail closed:
- A relative URL like
/thank-youalways works. - An absolute URL like
https://example.com/thank-youonly works if that domain is on your form's allowlist. With an empty allowlist, absolute redirects are rejected outright. This is deliberate: it stops a stolen key from bouncing your visitors to a phishing page. An invalid redirect returns400 invalid_redirectand nothing is stored, so you'll notice immediately rather than silently losing submissions.
Use redirect for maximum simplicity and resilience; use JSON for a smoother single-page feel.
Stopping spam without CAPTCHA
CAPTCHA puzzles annoy real users and hurt conversions. You can stop most bot spam with three quiet techniques, no puzzles required.
- Honeypot field: a hidden input real users never fill in, bu
Comments
No comments yet. Start the discussion.