Last week we pasted the code from 7 online HTML form generators into a blank page, and none of the forms delivered a message. So we wrote a small generator of our own to see what it takes to get the output right. It's one HTML file, about 205 lines with the CSS, and no framework or build step. The full file is at the end of this post.

The generator: field list on the left, live preview and code on the right

You edit a list of fields on the left, and the preview and the code on the right change as you type. The code it gives you posts to a real endpoint, gives every field a label and a name, has a honeypot for bots, and only says thank you when the server confirms it stored the message.

Start from a list of fields

Everything comes from one array. The UI edits it, and the generator reads it.

let fields = [ { label: "Name", type: "text", required: true }, { label: "Email", type: "email", required: true }, { label: "Message", type: "textarea", required: true }, ]; 

We kept name out of this object on purpose. People type labels, not attribute names, so the generator works the name out from the label every time it builds the code.

Work out a name the backend can use

The browser only sends fields that have a name, and two fields with the same name arrive under one key, so you can't tell the answers apart. So the label becomes a safe slug, and repeats get a number:

const slug = (s) => s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "field"; function uniqueNames(list) { const seen = {}; return list.map((f) => { let name = slug(f.label); seen[name] = (seen[name] || 0) + 1; if (seen[name] > 1) name += "_" + seen[name]; return { ...f, name }; }); } 

Two fields both labelled "Email" come out as email and email_2, and "Your e-mail!" turns into your_e_mail.

Escape everything the user typed

Labels and select options end up inside HTML, and people type quotes, ampersands and angle brackets. Without escaping, a label like Size "XL" cuts the attribute short and the form breaks in ways that are hard to see. One function covers it, and every user string goes through it:

const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]); 

We tested it with a label of Your e-mail <b>"x" and an option of Support & <help>. Both showed up in the generated form as plain text, with no bold and no broken markup.

Give every field a label and an autocomplete hint

Each field gets an id, and its <label for> points at it. That's what screen readers announce, and it lets people tap the label to focus the field. Name, email and phone fields also get autocomplete, so browsers and password managers can fill them in. This saves a lot of typing on a phone.

const AUTOCOMPLETE = { name: "name", full_name: "name", email: "email", phone: "tel", company: "organization" }; function fieldHtml(f) { const id = "f-" + f.name; const req = f.required ? " required" : ""; const ac = AUTOCOMPLETE[f.name] ? ` autocomplete="${AUTOCOMPLETE[f.name]}"` : ""; const label = `<label for="${id}">${esc(f.label)}</label>`; if (f.type === "textarea") { return ` <p>${label}\n <textarea id="${id}" name="${f.name}" rows="5"${req}></textarea></p>`; } // checkbox and select are in the full file return ` <p>${label}\n <input type="${f.type}" id="${id}" name="${f.name}"${ac}${req}></p>`; } 

Point the form at something that receives it

This is the step most generators leave as a placeholder. A form needs an action that accepts a POST and does something with it. Our page has a field for the URL and one for an access key, which goes into a hidden input. It also adds a honeypot: a text field that real visitors never see, and that bots tend to fill in.

lines.push(`<form id="contact-form" action="${action}" method="POST">`); if (key) lines.push(` <input type="hidden" name="access_key" value="${esc(key)}">`); lines.push(...list.map(fieldHtml)); lines.push(` <div aria-hidden="true" style="position:absolute;left:-9999px"> <input type="text" name="botcheck" tabindex="-1" autocomplete="off"> </div>`); 

We used our own backend's URL as the default because it's the one we can test. Any form backend works, and so does your own server route. Change the field name to whatever your backend expects for the key.

Ship the form with its own styles

Raw HTML inputs look like 1998, and people judge a form before they fill it in. So the output starts with a short <style> block (there's a checkbox to leave it out if your site has its own). Every rule starts with #contact-form, so it can't restyle anything else on the page you paste it into:

#contact-form { max-width: 520px; display: grid; gap: 16px; font: 16px/1.5 system-ui, sans-serif; } #contact-form p { margin: 0; display: grid; gap: 6px; } #contact-form :is(input, textarea, select) { width: 100%; padding: 10px 12px; border: 1px solid #d0d5dd; border-radius: 8px; } #contact-form :is(input, textarea, select):focus { outline: 2px solid #4f46e5; outline-offset: 1px; } #contact-form button { padding: 11px 22px; border: 0; border-radius: 8px; background: #4f46e5; color: #fff; font-weight: 600; } #contact-form [data-status]:empty { display: none; } 

Two small choices in there are worth copying. The focus outline is a real 2px ring, not outline: none, so keyboard users can see where they are. And the status line hides itself while it's empty, so there's no blank gap under the button until there's something to say.

Say thank you only when the server agrees

The generated form also gets a short script. It sends the form with fetch and reads the JSON reply. It shows the thank-you only when the reply says the message was stored. A 404 or a 401 doesn't make fetch throw, so without that check a broken setup still looks like it works.

const res = await fetch(form.action, { method: "POST", headers: { Accept: "application/json" }, body: new FormData(form), }); const data = await res.json().catch(() => ({})); if (res.ok && data.success === true) { form.reset(); status.textContent = "Thank you, your message was sent."; } else { status.textContent = "That didn't go through. Please try again."; } 

Watch for one trap if you keep this script as a string inside your own <script> tag: the text </script> ends your script right there, even inside a template string. Write it as <\/script>.

The "data binding" is one function

No state library, no virtual DOM. Any input changes, update() runs, builds the whole code string again from the array and puts it into the code box and into the preview iframe:

function update() { const code = generate(); document.querySelector("#code").textContent = code; document.querySelector("#preview").srcdoc = `<!doctype html><meta charset="utf-8"><body style="margin:0;padding:20px">${code}`; } 

Honestly, we thought this would be the first thing to get slow. It wasn't: 100 calls in a row averaged 0.12 ms each with five fields. The rows on the left work the same way. renderRows() wipes them and draws them again when you add a field, remove one or change its type.

One detail we'd keep even in a toy: the preview iframe has sandbox="allow-forms allow-scripts" and no allow-same-origin. Someone will paste a script into a label sooner or later, and this way it runs in a box that can't see the generator.

How we checked the output

DOMParser on the generated code, then a loop over every field: does it have a name, does a label point at it. All did. The <b> from our nasty test label came through as text, and the second "Email" turned into email_2.

Then a real submit to our endpoint, placeholder key and all. We got "That didn't go through", and the fields still had our test text in them. So the failure path works. The success path we couldn't run without a real key, which means it's on you: one test message with your key before the form goes live.

What it leaves out

Drag-and-drop ordering. File uploads, which need enctype="multipart/form-data" and a backend that accepts files. Custom error messages (you get the browser's). None of these change the shape of the thing, array in and string out, they're just more lines.

If you'd rather not maintain one, our form generator exports HTML, Tailwind, Bootstrap, React and Vue.

The full file (205 lines)
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>HTML form generator</title> <style> :root { --bg: #f5f6fa; --card: #fff; --line: #e1e4ec; --text: #1c2230; --muted: #667085; --accent: #4f46e5; --accent-soft: #eef0ff; } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 Inter, system-ui, sans-serif; } .app { max-width: 1180px; margin: 0 auto; padding: 32px 20px; display: grid; gap: 24px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } @media (max-width: 860px) { .app { grid-template-columns: 1fr; } } h1 { grid-column: 1 / -1; margin: 0; font-size: 26px; letter-spacing: -0.02em; } h1 small { display: block; font-size: 15px; font-weight: 400; color: var(--muted); letter-spacing: 0; } .card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 18px; margin-bottom: 20px; box-shadow: 0 1px 2px rgb(16 24 40 / 0.04); } .card h2 { margin: 0 0 14px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); display: flex; justify-content: space-between; align-items: center; } .row { display: grid; grid-template-columns: 1.6fr 1fr auto auto; gap: 8px; align-items: center; padding: 8px; margin: 0 -8px; border-radius: 10px; } .row:hover { background: var(--bg); } .row > input:last-child:not([type]) { grid-column: 1 / 3; } input, select, button { font: inherit; color: inherit; } input:not([type=checkbox]), select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: #fff; } input:focus, select:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; vertical-align: -3px; } .check { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 14px; white-space: nowrap; } .stack { display: grid; gap: 12px; } .stack label:not(.check) { display: grid; gap: 4px; font-size: 14px; color: var(--muted); } button { cursor: pointer; border-radius: 8px; padding: 8px 14px; border: 1px solid var(--line); background: #fff; } button:hover { border-color: var(--accent); color: var(--accent); } .remove { width: 34px; height: 34px; padding: 0; color: var(--muted); } #add { margin-top: 8px; background: var(--accent-soft); border-color: transparent; color: var(--accent); font-weight: 600; } #copy { background: var(--accent); border-color: var(--accent); color: #fff; font-size: 13px; padding: 6px 12px; text-transform: none; letter-spacing: 0; } iframe { width: 100%; height: 620px; border: 0; border-radius: 10px; background: #fff; } pre { margin: 0; background: #0f172a; color: #e2e8f0; border-radius: 10px; padding: 16px; overflow: auto; max-height: 420px; font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; } </style> </head> <body> <div class="app"> <h1>HTML form generator <small>Edit the fields, copy the code. The form posts to a real endpoint.</small></h1> <main> <section class="card"> <h2>Fields</h2> <div id="rows"></div> <button type="button" id="add">+ Add field</button> </section> <section class="card stack"> <h2>Where it sends</h2> <label>Form action URL <input id="action" type="url" value="https://formgong.com/submit"></label> <label>Access key (hidden field) <input id="key" value="fk_your_access_key"></label> <label class="check"><input id="honeypot" type="checkbox" checked> Honeypot field for bots</label> <label class="check"><input id="script" type="checkbox" checked> Send with fetch and check the answer</label> <label class="check"><input id="styles" type="checkbox" checked> Include styles</label> </section> </main> <aside> <section class="card"> <h2>Preview</h2> <iframe id="preview" title="Form preview" sandbox="allow-forms allow-scripts"></iframe> </section...
Source:https://dev.to/formgongteam/how-to-make-an-html-form-generator-that-sends-16ae