Protecting Custom HTML and JavaScript Forms: A Drop-in Moderation Pattern
Plugin ecosystems get all the anti-spam attention, but plenty of the web is still hand-built: a static marketing site on Netlify, a Vue or React app posting to an API, a plain HTML contact form that has quietly worked for years. There is no plugin directory to save you here. If you want moderation, you wire it in yourself — which is actually an advantage, because you control exactly where it runs.
Here is a pattern that works across custom HTML, single-page apps, and static/JAMstack sites, without a CAPTCHA and without dragging in a heavyweight backend.
The Two-Layer Rule
Before any code, one principle that trips people up: never trust the client alone. Anything that runs in the browser can be bypassed by anyone who opens the network tab. A client-side check is great for user experience — instant feedback, no round trip to your server for obvious cases — but it is not security. A bot posting straight to your endpoint skips your JavaScript entirely.
So the pattern is two layers:
- Client-side scoring for a fast, friendly experience — catch bad input before the user even submits.
- Server-side scoring at the point where the submission is actually processed — the layer that actually protects you, because it runs where the bot cannot skip it.
On a static site with no server of your own, "server-side" is a serverless function — a Netlify Function, a Vercel Function, a Cloudflare Worker. That is the piece that makes the whole thing trustworthy.
Layer 1: Client-Side, for UX
The drop-in script gives you a scoring call you can run on submit. This is the part that makes the form feel responsive:
<form id="contact">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script src="https://cdn.babelshield.com/v1/babelshield.js" data-key="pk_live_yourPublicKey"></script>
<script>
const form = document.getElementById('contact');
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const scores = await babelshield.score(form.message.value);
if (scores.spam > 0.8 || scores.profanity > 0.7) {
showInlineError("That message looks like spam — please revise it.");
return;
}
} catch (_) {
// If scoring fails, don't block the user here — the server check is the real gate.
}
submitToBackend(new FormData(form));
});
</script>
Note the public key. The client-side call uses a publishable key that is safe to expose, exactly like a payment provider's publishable key. It is fine for scoring and rate-limited accordingly — it is not the key that makes the decision that matters.
Layer 2: Server-Side, for Real
The real gate lives in the function that handles the submission, using your secret key. Here it is as a serverless handler:
// netlify/functions/submit.js (or a Vercel / Cloudflare Worker)
export async function handler(event) {
const { email, message } = JSON.parse(event.body);
const res = await fetch("https://api.babelshield.com/v1/score", {
method: "POST",
headers: {
"Authorization": Bearer ${process.env.BABELSHIELD_SECRET_KEY},
"Content-Type": "application/json"
},
body: JSON.stringify({ text: message })
});
const scores = await res.json();
if (scores.spam > 0.8 || scores.hate > 0.5 || scores.junk > 0.85) {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
// Silently accept-and-drop: the bot thinks it succeeded; nothing reaches you.
}
await deliver({ email, message }); // email it, store it, push to your CRM
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}
(Endpoint, field names, and thresholds are illustrative — check the current Babelshield docs.)
There is a small tactical choice worth making here. When the server catches spam, you can return an error — or you can return success and quietly drop the submission, as above. Silently accepting-and-dropping means a bot gets no signal that it was blocked, so it does not adapt or retry. Real users never see this path, because their submissions pass. It is one of the quiet advantages of moderating on content rather than throwing up a visible CAPTCHA wall.
Static Sites Without Your Own Function
If you are using a hosted form backend — Netlify Forms, Formspree, Getform — you may not have a function in the loop at all. Two options. If the backend supports webhooks, point the webhook at a small function that scores the submission and files or discards it after the fact. If it does not, do the scoring in the client before handing off, and accept that it is best-effort rather than airtight. Best-effort content scoring still catches the overwhelming majority of the junk that a static contact form attracts, and it beats the honeypot-only setup most of these forms ship with.
Why This Beats a CAPTCHA on a Custom Form
On a hand-built form you are usually optimising hard for conversion — it is a marketing site, a signup, a lead capture. A CAPTCHA is the last thing you want in that flow, and on a static site it also means pulling in a third-party widget and its privacy baggage. Content scoring adds nothing to the user's path: the form looks and behaves exactly as it did, the check happens in the function they never see, and you get clean submissions without asking anyone to identify a crosswalk.
Hand-built forms give you total control over the request lifecycle. Use it: score on the client for a nice experience, score on the server for real protection, and keep the junk out without ever making a real visitor prove they are human.
References
MDN Web Docs. Working with forms and the Fetch API.
Netlify / Vercel / Cloudflare. Serverless function and Workers documentation.
OWASP. Never trust client-side validation for security decisions.