
Embed a Contact Form in HTML: Copy-Paste Code That Works
To embed a contact form, you paste a <form> into your page and point its action at something that can receive the submission. The markup takes two minutes. Most broken contact forms fail on the other half: the form posts to nowhere, the honeypot has a name the backend doesn't recognise, or the thank-you redirect loads inside an iframe the size of a postage stamp.
This guide gives you contact form embed code you can copy as it is: HTML with a small stylesheet, a honeypot, and a redirect to your own thank-you page. Then it covers the three ways to put a form on a page (inline HTML, an iframe, or a script embed), what each one costs you, and where to paste the code in WordPress, Webflow, Wix, Google Sites and static site generators.
We make Static Forms, a form backend, and the examples post to it. The embedding advice holds for any backend that accepts a normal HTML form post. If you only need a plain form without the embedding detail, the simple contact form guide is shorter.
The copy-paste contact form embed code
This is the whole form. Paste it into any HTML page, replace YOUR_API_KEY with the key from your form's General tab, and set redirectTo to the full URL of your own thank-you page.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact</title>
<style>
.contact-form { max-width: 560px; display: grid; gap: 1rem; font-family: system-ui, sans-serif; }
.contact-form .row { display: grid; gap: 0.35rem; }
.contact-form label { font-weight: 600; }
.contact-form input,
.contact-form textarea {
width: 100%;
box-sizing: border-box;
padding: 0.7rem;
font: inherit;
border: 1px solid #c9ced6;
border-radius: 6px;
}
.contact-form textarea { resize: vertical; min-height: 8rem; }
.contact-form button {
justify-self: start;
padding: 0.75rem 1.25rem;
font: inherit;
color: #ffffff;
background: #1f2937;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.contact-form input:focus-visible,
.contact-form textarea:focus-visible,
.contact-form button:focus-visible { outline: 3px solid #fe5b5b; outline-offset: 2px; }
</style>
</head>
<body>
<form class="contact-form" action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_API_KEY">
<input type="hidden" name="subject" value="New message from the website">
<input type="hidden" name="redirectTo" value="https://YOUR-SITE.com/thank-you">
<div class="row">
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="name" required>
</div>
<div class="row">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
</div>
<div class="row">
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
</div>
<input type="text" name="honeypot" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">Send message</button>
</form>
</body>
</html>If you're adding the form to an existing page, copy the <style> block into your stylesheet and the <form> element into the page. The rest is there so the file works on its own when you test it.
What each field does
Every input with a name ends up in the notification email. A few names have a job:
| Field | What it does |
|---|---|
apiKey |
Required. Tells Static Forms which form the submission belongs to. |
email |
Becomes the reply-to address, so you can answer from your inbox. |
subject |
Optional. The subject line of the notification email. |
redirectTo |
Optional. Where the visitor goes after a successful submission. It must be a full http:// or https:// URL; a relative path like /thanks is ignored. |
honeypot |
Any field whose name contains "honeypot". People never see it; a submission that fills it in is treated as spam. |
Two names to avoid: a field called _redirect does nothing here, because the redirect field is redirectTo; and a honeypot with an unrelated name such as company_website is just another form field. Name the field honeypot and it works with no setup. The honeypot docs list the other accepted names.
Without redirectTo, a successful post shows a plain "Form submitted successfully" page. With it, the visitor gets a 303 redirect to your page.
Would you rather pick fields than edit markup? The free HTML form generator builds the same kind of form in your browser, and the form templates give you styled starting points.
Inline HTML, iframe or script embed: which one to use
You can put a form that someone else processes on your page in three ways. A visitor can't tell them apart, but they differ in who styles the form, how tall it can grow, and where the thank-you page lands.

Inline HTML is the form above, pasted into your page. The form is part of your document, so your CSS styles it, your layout makes it responsive, and the form text is on your URL. Nothing loads before the form shows up. The redirect replaces your page the way any link would.
An iframe shows another document inside a box on your page. That other page could be a hosted form from a form builder, or a page you host yourself. Your stylesheet doesn't reach inside it, and the box has a height you set, so a form that grows (an error message, a longer layout on a phone) gets cut off or scrolls inside the box. A form inside an iframe submits within the frame, so a thank-you redirect loads inside that box too, unless the form has target="_top", which MDN describes as loading into "the top-level browsing context".
A script embed is a <script> tag that draws the form for you or adds behaviour to your own markup. Static Forms has one, staticforms.js, for forms built in the Form Builder. It sends with fetch, so the visitor stays on the page and sees an inline message. It needs JavaScript to run, and when the builder is on, every submission to that API key is checked against the fields you saved there, including a hand-written HTML form on the same key.
| Inline HTML | iframe | Script embed | |
|---|---|---|---|
| Styled by your CSS | Yes | No | Your markup with attach; the embed's own markup with render |
| Works without JavaScript | Yes | Depends on the framed page | No |
| Height | Follows the content | Fixed by you | Follows the content |
| After submit | Redirect replaces your page | Loads inside the frame unless the form targets _top |
Inline message on your page |
| Accessibility work | Labels on each field | A title on the iframe, plus the framed page's own labels |
Labels from the embed or your markup |
Use inline HTML when you can edit the page's HTML. That's most static sites, WordPress with a Custom HTML block, and every static site generator. Use an iframe only when the platform forces it, or when the form lives on a page you want to reuse across several sites. Use the script embed when you want the form managed from a dashboard or need an inline success message without writing the fetch code.
If you do use an iframe
Give it a title. MDN notes that screen reader users can use it to identify the frame; without one, they have to go inside to find out what it is. Add loading="lazy" if the form is below the fold, so the frame isn't fetched until the visitor scrolls near it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Contact</title>
</head>
<body>
<iframe
src="https://example.com/contact-embed.html"
title="Contact form"
width="100%"
height="640"
loading="lazy"
style="border:0; max-width:560px;"></iframe>
</body>
</html>On contact-embed.html itself, add target="_top" to the <form> so the thank-you page replaces the whole window instead of loading in the 640-pixel box. If the iframe has a sandbox attribute, _top only works when it includes allow-top-navigation or allow-top-navigation-by-user-activation. Without one, the browser blocks the redirect and the visitor sees nothing happen. target="_blank" opens the thank-you page in a new tab instead, which needs allow-popups.
If you put a sandbox attribute on the iframe, include allow-forms. Per MDN, without it "a form will be displayed as normal, but submitting it will not ... send data to a web server". You see no error, and the submission never reaches the backend.
Content Security Policy blocks embeds without telling you
If your site sends a Content-Security-Policy header, check it before you blame the form:
form-actionrestricts where forms on your page may submit. It doesn't fall back todefault-src, so it only matters if you set it, and then it must includehttps://api.staticforms.dev.frame-srccontrols where your page's iframes can load from. An iframe embed needs its source origin listed.frame-ancestorsis set by the page being framed and decides who may embed it.frame-ancestors 'none'means the page can't be put in an iframe anywhere. If a hosted form shows as a blank box, this header on the form's page is the usual cause, and nothing you change on your side fixes it.script-srcandconnect-srcmatter for a script embed: the script's origin, and the origin it sends the submission to.
The browser console names the directive that blocked the request.
Where to paste the code on each platform
The form doesn't change between platforms. What changes is whether the platform puts your code inline or in a frame, and that decides which version of the form to use.
| Platform | Where the code goes | Inline or framed | Which version |
|---|---|---|---|
| Plain HTML | The page file | Inline | The form above |
| Hugo, Jekyll, Eleventy, Astro | A partial or include | Inline | The form above |
| WordPress | A Custom HTML block | Inline | The form above |
| Webflow | Your Form Block, with hidden fields in a Code Embed | Inline | Webflow's form, action changed |
| Wix | An Embed HTML element | Framed, on a Wix domain | The fetch version below |
| Google Sites | Insert, Embed, Embed code tab | Framed | The fetch version below |
| React or Next.js | A component | Inline | The JSX version below |
Plain HTML and static site generators
Paste the form into the page. In Hugo, Jekyll, Eleventy or Astro, put it in a partial or include so the contact page, footer and landing pages share one copy. If your generator has site config, keep the API key and redirect URL there instead of hard-coding them in three places. The HTML setup docs cover the same form with CAPTCHA options and a field reference.
WordPress
Add a Custom HTML block in the block editor and paste the <form> element. Leave out the <html>, <head> and <body> wrapper; WordPress already has those. Some themes and security plugins strip attributes from pasted HTML, so view the published page source once and check that action, name and the hidden fields survived. The WordPress guide covers the plugin route, and a contact form without a plugin goes through the block method in detail.
Webflow
Keep Webflow's Form Block, so the form keeps your design. Set its action to https://api.staticforms.dev/submit and its method to POST, then drop a Code Embed element inside the form for the hidden apiKey, redirectTo and honeypot inputs. Test on the published domain, not in the Designer. The Webflow setup has the exact steps.
Wix and Google Sites: use the fetch version
Both platforms put pasted code in a frame. Wix serves that frame from its own domain, and Google Sites runs embedded code in a sandboxed iframe too. A plain form post with a redirect would load your thank-you page inside the frame. Whether target="_top" can get out of that frame depends on the frame's sandbox flags, and Google doesn't document them for Sites. So for these two, send with fetch and show the result in place:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Contact</title>
<style>
.sf-contact { display: grid; gap: 8px; max-width: 480px; font-family: system-ui, sans-serif; }
.sf-contact input, .sf-contact textarea { padding: 8px; font: inherit; }
.sf-contact button { padding: 10px; font: inherit; cursor: pointer; }
</style>
</head>
<body>
<form class="sf-contact" action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_API_KEY">
<label for="sf-name">Name</label>
<input type="text" id="sf-name" name="name" required>
<label for="sf-email">Email</label>
<input type="email" id="sf-email" name="email" required>
<label for="sf-message">Message</label>
<textarea id="sf-message" name="message" rows="5" required></textarea>
<input type="text" name="honeypot" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">Send message</button>
<p class="sf-status" role="status"></p>
</form>
<script>
const form = document.querySelector('form.sf-contact');
const status = form.querySelector('.sf-status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
status.textContent = 'Sending...';
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' },
});
const isJson = (response.headers.get('content-type') || '').includes('application/json');
const result = isJson ? await response.json() : {};
if (result.success) {
status.textContent = 'Thanks! Your message has been sent.';
form.reset();
} else {
status.textContent = result.error || 'Something went wrong. Please try again.';
}
} catch {
status.textContent = 'Network error. Please try again.';
}
});
</script>
</body>
</html>There's no redirectTo here on purpose. A redirect would send fetch to your thank-you page instead of returning the JSON result.
In Google Sites, the embed box has a fixed height; drag it taller in the editor if the form is cut off. The Google Sites contact form tutorial walks through the editor, and the Wix setup covers the Embed HTML element.
One more catch for framed builders: if you turn on domain restriction (Starter and up), the request comes from the frame's domain, not yours, so a list that names only your own domain rejects these submissions with a 403. Don't fix that by adding the builder's shared host to the list. Domain matching includes subdomains, so allowing a shared host would let every other site on it submit with your key. Leave domain restriction off for forms in Wix or Google Sites. A sandboxed frame can send no origin at all, which gives a different 403, "Unable to determine origin domain", with no domain to add.
React and Next.js
A contact form that posts to a hosted endpoint doesn't need state for every keystroke. A native form in a component works, and the browser handles the redirect:
export default function ContactForm() {
return (
<form action="https://api.staticforms.dev/submit" method="POST">
<input type="hidden" name="apiKey" value="YOUR_API_KEY" />
<input type="hidden" name="redirectTo" value="https://YOUR-SITE.com/thank-you" />
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required />
<input type="text" name="honeypot" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<button type="submit">Send message</button>
</form>
);
}Switch to fetch and component state only when you need an inline success message or custom error UI.
Spam, file uploads and the thank-you page
Spam. The honeypot in the form catches bots that fill in every field, and the built-in spam filter runs on every plan with nothing to add. If spam still gets through, add a CAPTCHA in the form's Security settings. reCAPTCHA v2 is available on the Free plan; Cloudflare Turnstile, hCaptcha and ALTCHA are Starter and up. Once a CAPTCHA is on, submissions without a valid token are rejected, so add the widget to the form in the same change.
File uploads. Uploads are Starter and up. Add enctype="multipart/form-data" to the form and a file input with a name. The whole request, files and fields together, must stay under 4.5 MB. The HTML file input reference covers accept, multiple and an accessible file picker.
Thank-you page. Create a real page for it, and use its full URL in redirectTo. It gives the visitor a clear confirmation and gives you one URL to count conversions on.
Troubleshooting an embedded contact form
Submit the live form once with the browser's network tab open. The request URL, status code and response body tell you which layer failed.
| What you see | Why, and what to do |
|---|---|
| A plain "Form submitted successfully" page | It worked. Add redirectTo with a full URL to show your own page. |
redirectTo is ignored |
It must start with http:// or https://. A relative path is ignored. |
| The thank-you page opens inside a small box | The form is in an iframe. Add target="_top" to the form, or use the fetch version. In a sandboxed frame, _top can be blocked; use target="_blank" or the fetch version, and test one submission. |
| The iframe is blank | The framed page sends frame-ancestors or X-Frame-Options that forbid embedding, or your frame-src doesn't allow its origin. Check the console. |
| The button does nothing and nothing arrives | A sandboxed iframe without allow-forms, or a form-action directive that doesn't list https://api.staticforms.dev. |
| "Invalid API key" | The apiKey value doesn't match the key on the form's General tab. Check for spaces. |
403 naming a domain, or "Unable to determine origin domain" |
Domain restriction is on, and the request came from a domain not on the list, or with no origin (common in Wix and Google Sites embeds). Turn restriction off for framed forms rather than adding a shared host. |
| Visitors land on a wrong or placeholder page, though email arrives | redirectTo still holds the placeholder. Replace it with your real thank-you page URL. |
| Fields missing from the email | Those inputs have no name. The browser only sends named fields. |
| Spam still arrives with a honeypot | Check the field's name contains "honeypot". Naming it honeypot is the reliable choice. |
| Works in the editor, fails when published | Test on the published URL. Builders and caching plugins change markup between the editor and the live page. |
Get a form running
Create a free form at Static Forms, copy its API key into the form above, and paste it into your page. The HTML setup docs have the full field reference, and the form generator builds the markup if you'd rather pick fields than write them.
Related Articles
Contact Form on a Static WordPress Site (Simply Static)
Add a contact form to a static WordPress site that survives a Simply Static or WP2Static export: why plugin forms break, the HTML fix and how to test it.
Make a Claude-Generated Website Contact Form Send Email
Fix a Claude-generated website contact form that fakes success. Trace the handler, patch HTML or React, protect secrets, and test the published Artifact.
Add Email to a Replit Website Contact Form
Connect a Replit website contact form to email with plain HTML and React examples, safe key handling, publishing checks, spam controls, and fixes.









