A Report Form for a Static Site with No Backend
A working report form is measured by whether a submitted report actually reaches the person who can fix the problem, and whether random bot requests really get stopped without sending anything, rather than by how the form looks.
I had built a structural calculation site made entirely of static files: HTML and JavaScript that run the calculations in the user's browser. No database, no PHP, not a single line running on the server. I put it on Cloudflare's static hosting service, and that was it. Hosting cost nothing, and it was very fast.
The problem came when someone told me one of the formulas was producing strange results. I asked which page they were on and what they had entered, but I never got a complete answer. Eventually, I decided the site needed a report button that would send me a message, a screenshot, and the page they were on, directly.
But the site had no backend, so there were only a few options, and I chose the one that did not require another server.
I made up all the domain names, keys, destination email addresses, and account numbers in this article. The code structure, the order of the checks, and the traps I ran into are all from the actual job.
Entirely static, but with one receiving endpoint
The starting requirement was simple: the whole site had to remain static files, except for one route that needed server-side code—the route receiving problem reports.
There were three options at the time. First, rent a small PHP hosting account for a single form handler file. Second, use someone else's hosted form service. Third, write a small piece of code to run at the existing hosting provider's network edge.
I ruled out the first option immediately, because another server meant another machine to update, renew certificates for, and monitor, all for one form. I ruled out the second because I did not want to pass users' messages through a third party unnecessarily. That left the third option, which became the final choice.
A functions folder that would not compile, and a single-file solution
This static hosting service offers two ways to add server-side code. The first is to create a folder called functions/ and place files in it according to the routes you want; the service compiles them during deployment. The second is called advanced mode: place a _worker.js file at the root of the folder being deployed, and that file becomes the handler for every request to the site.
I started with the first method because it looked like the standard approach, and then spent far too long stuck there.
When the functions folder failed to compile, the result was not a failed deployment, but a successful deployment with that route missing. Requests to it returned the static site's 404 page, which looked exactly like a routing mistake. I spent a long time changing filenames and folder locations before going back to the deployment logs and seeing that it had silently skipped compilation from the start. If a route is missing, always read the deployment logs first. Do not start changing the code yet.
Once I switched to advanced mode, everything fit in one file, with no compilation step to break. But there are tradeoffs to understand first.
Convenient for multiple routes
- Each route has its own file, making it easy to read
- Static files are served automatically without any code from me
- Deployment includes a compilation step that can fail silently
- Suited to projects with more than ten routes
One file, handling every request myself
- No compilation step; the file I write is the file that runs
- I must explicitly forward the remaining requests to the static assets
- Forget that line, and the entire site becomes a blank page
- Suited to projects with one or two routes like this
The skeleton of the entire file is this short, with the routing logic right at the top.
// _worker.js — โหมดขั้นสูง: ไฟล์นี้รับทุกคำขอของทั้งเว็บ
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/report') {
if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
return handleReport(request, env);
}
// ทุกอย่างที่ไม่ใช่ /report ส่งต่อให้ตัวเสิร์ฟไฟล์นิ่ง
// บรรทัดนี้หายเมื่อไร ทั้งเว็บกลายเป็นหน้าว่างทันที
return env.ASSETS.fetch(request);
}
};
function json(o, s = 200) {
return new Response(JSON.stringify(o), {
status: s,
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
});
}
The line env.ASSETS.fetch(request) is the one people most often forget, because in this mode nothing else serves the static files for me. I serve them myself. I recommend writing that line before anything else, then deploying to check that the existing site still works, and only adding the new route afterward.
Verify the bot check on the server, not on the page
This is the most common mistake with public forms: adding a bot-check widget to the page, seeing a green checkmark, and letting the submission through without ever verifying the result with the provider.
What the bot-check widget returns to the page is a string that must be checked with the provider to see whether it is genuine. If I skip that check, anyone can send requests directly to the form endpoint with a single command, without even opening the page, and put any value they like in that field. Verification must always happen on the server, before doing anything else.
The second common mistake is the IP address sent to the provider for verification. When my code runs at the network edge, the IP visible at the connection level is not the form submitter's IP. I have to read it from the header supplied by the proxy instead.
async function verifyBot(token, ip, env) {
const p = new URLSearchParams();
p.set('secret', env.TURNSTILE_SECRET || ''); // ค่าเก็บเป็นความลับของโปรเจกต์ ไม่อยู่ในซอร์ส
p.set('response', token);
p.set('remoteip', ip); // ไอพีจริงของคนกรอก ไม่ใช่ของตัวแทน
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: p
});
const vr = await r.json();
return vr.success === true;
}
Then, inside the form handler, the order must be exactly this: read the form, check the minimum message length, verify the bot check, and only then handle the image and email.
async function handleReport(request, env) {
try {
const form = await request.formData();
const message = (form.get('message') || '').toString().trim();
const page = (form.get('page') || '').toString().slice(0, 300);
const contact = (form.get('contact') || '').toString().slice(0, 160);
const token = (form.get('cf-turnstile-response') || '').toString();
const img = form.get('image');
if (message.length < 3) return json({ ok: false, error: 'empty' }, 400);
const ip = request.headers.get('CF-Connecting-IP') || '';
if (!await verifyBot(token, ip, env)) return json({ ok: false, error: 'captcha' }, 403);
const attachments = [];
if (img && typeof img.arrayBuffer === 'function' && img.size > 0) {
if (img.size > 5 * 1024 * 1024) return json({ ok: false, error: 'image_too_big' }, 413);
attachments.push({
filename: (img.name || 'screenshot.png'),
content: toB64(await img.arrayBuffer())
});
}
return sendMail({ message, page, contact, ip, attachments }, env);
} catch (e) {
return json({ ok: false, error: 'server', detail: String(e).slice(0, 200) }, 500);
}
}
Notice that I truncate every field received from the user on the line where I read it, rather than when I use it. The page field is limited to three hundred characters and the contact field to one hundred and sixty, because these are the two fields bots most often use to stuff in long messages.
A bot-check widget in a modal, and rendering best left alone
My form sits in a modal that opens over the current page, rather than on a separate page. At first, I took what seemed like the smarter approach: explicitly rendering the bot-check widget when the modal opened, because I thought I should not load something that was not yet needed.
The result was the most annoying kind of failure: it worked normally the first time, but after closing and reopening the modal, the widget either did not appear or appeared and stayed stuck without returning anything. Sometimes I got two overlapping widgets.
I ended up letting the provider's script render the widget when the page loaded, placing it inside the hidden modal. When the modal opens, I just reset it once, then reset it again after every failed submission. The token the widget returns is always single-use. If I do not reset it and the user submits again, the second attempt is guaranteed to fail verification.
<!-- กล่องอยู่ในหน้าต่างลอยตั้งแต่แรก ปล่อยให้เรนเดอร์เองตามปกติ -->
<div class="ts-wrap"><div class="cf-turnstile" data-sitekey="YOUR_SITEKEY_HERE" data-theme="light"></div></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
function openReport() {
document.getElementById('reportModal').classList.add('on');
document.body.style.overflow = 'hidden';
try { if (window.turnstile) turnstile.reset(); } catch (e) {} // ค่าเก่าใช้ซ้ำไม่ได้
document.addEventListener('paste', onPaste);
}
function closeReport() {
document.getElementById('reportModal').classList.remove('on');
document.body.style.overflow = '';
document.removeEventListener('paste', onPaste); // ถอดตัวดักออกทุกครั้ง
}
One small detail worth noticing is the clipboard paste event handler. I attach it when the modal opens and always remove it when the modal closes. If I leave it attached across the entire page, anything a user pastes on the site gets swallowed by this handler, which is behavior the user did not ask for.
Accept pasted images without asking anyone to save a file first
The thing that makes a report form ten times more useful is a screenshot. But asking users to click a file picker means they have to capture the screen, open a paint program, paste it, save a file, and then select it, which is where most people give up.
The shortest path is to let them capture the screen and paste it straight into the form.
function onPaste(e) {
const items = (e.clipboardData || {}).items || [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') === 0) {
setImage(items[i].getAsFile()); // ได้ File มาตรงๆ ใช้กับ FormData ได้เลย
e.preventDefault();
return;
}
}
}
function submitReport() {
const fd = new FormData();
fd.append('message', document.getElementById('rMsg').value);
fd.append('contact', document.getElementById('rContact').value);
fd.append('page', location.pathname + location.search);
fd.append('cf-turnstile-response', window.turnstile ? turnstile.getResponse() : '');
if (currentImage) fd.append('image', currentImage, currentImage.name || 'screenshot.png');
fetch('/report', { method: 'POST', body: fd })
.then(r => r.json().catch(() => ({ ok: r.ok })))
.then(showResult);
}
On the server, I have to convert the image bytes to base64 text before sending them in the email. A straightforward implementation that spreads the entire array into a single function call will crash with a call stack overflow on an image of two or three megabytes. It has to be split into smaller chunks.
function toB64(buf) {
let b = '';
const u = new Uint8Array(buf);
const chunk = 0x8000; // ซอยทีละ 32768 ไบต์ ไม่งั้นกองซ้อนเรียกเกิน
for (let i = 0; i < u.length; i += chunk) {
b += String.fromCharCode.apply(null, u.subarray(i, i + chunk));
}
return btoa(b);
}
I set the five-megabyte limit based on the size of an uncompressed high-resolution screenshot, with a little room to spare. If the image exceeds it, I return the status code for content that is too large directly, then let the page turn that into an understandable Thai message instead of leaving the spinner running.
Email requests from my machine were blocked, but requests from the edge passed
To test whether the email provider's API worked, I first wrote a short script to call it from my own machine, because that was faster than deploying and then testing. The request was rejected with an error code from the website's front layer, indicating that a rule set by the destination domain's owner had blocked it.
I spent quite a while wondering whether the key was wrong, until I deployed the code and tried calling it from the actual network edge. It worked on the first attempt.
Many providers have rules blocking calls from residential or unknown IP addresses. Those are valid rules on their side, not bugs. When testing an API that will be called from a particular infrastructure, test from there from the start. Do not test from your own machine and conclude that the key does not work. I wasted time on this late into the night for no reason.
The complete email sender looks like this. The part to watch is escaping special characters before inserting user text into the email's HTML, because someone is bound to send angle brackets.
function esc(s) {
return String(s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
}
async function sendMail({ message, page, contact, ip, attachments }, env) {
if (!env.MAIL_API_KEY) return json({ ok: false, error: 'mail_unconfigured' }, 503);
const html = `<div style="font-family:system-ui,sans-serif;color:#1e293b">
<h3>แจ้งข้อผิดพลาดจากหน้าเว็บ</h3>
<p><b>หน้า:</b> ${esc(page)}</p>
${contact ? `<p><b>ผู้แจ้ง:</b> ${esc(contact)}</p>` : ''}
<pre style="white-space:pre-wrap;background:#f1f5f9;padding:12px;border-radius:8px">${esc(message)}</pre>
<p style="color:#94a3b8;font-size:12px">ไอพี ${esc(ip)} · ${new Date().toISOString()}</p>
</div>`;
const r = await fetch('https://api.example-mail.com/v1/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.MAIL_API_KEY}`,
'content-type': 'application/json'
},
body: JSON.stringify({
from: env.MAIL_FROM,
to: [env.MAIL_TO],
reply_to: contact && contact.includes('@') ? contact : undefined,
subject: 'แจ้งปัญหาจากเว็บ',
html,
attachments
})
});
if (!r.ok) return json({ ok: false, error: 'send', detail: (await r.text()).slice(0, 200) }, 502);
return json({ ok: true });
}
Another point I insist on is that the email address entered by the reporter must go only in the reply-to field. Never put it in the sender field, because sending email under someone else's domain is the most direct way to get my own domain blocklisted. The sender field must always be tied to my own domain.
Where secrets belong, and keeping them out of the source
The entire code file contains no keys, no secrets, and not even a single destination email address. Everything is read from the project's environment variables, set through the command-line tool or the service's settings page.
# ตั้งค่าลับของโปรเจกต์ (พิมพ์ค่าตอนที่มันถาม ไม่ใส่ในบรรทัดคำสั่งให้ติดประวัติเชลล์)
npx wrangler pages secret put TURNSTILE_SECRET
npx wrangler pages secret put MAIL_API_KEY
# ค่าที่ไม่ลับ ใส่เป็นตัวแปรธรรมดาได้
npx wrangler pages deploy ./public --project-name my-static-site
# ทดสอบว่าเส้นทางมีอยู่จริง ต้องได้ 403 เพราะยังไม่ผ่านกล่องกันบอต ไม่ใช่ 404
curl -s -o /dev/null -w '%{http_code}\n' -X POST -F 'message=ทดสอบ' https://example.com/report
A 404 means the route does not actually exist yet; the deployment did not take effect. A 200 means the bot check is not working and anyone can send requests. Only a 403 means the route exists and the verification is working. I put this command in the project's deployment notes because it is the fastest way to check that things still work after any change.
The order I would follow if I did this again
- Place a _worker.js file containing only the line that forwards requests to static assets, then deploy it. Confirm that the existing site has not changed at all. This is the stage where failure is immediately visible.
- Add a form endpoint that responds that it is not ready yet, then call it with a single command to confirm that the route actually exists.
- Add bot-check verification first in the handler. Test that a request without a token returns 403.
- Connect the page, add an automatically rendered bot-check widget, and reset it whenever the modal opens and after every failed submission.
- Connect the email API, testing only from deployed code, not from my own machine.
- Add image attachments afterward, because a failure there does not affect plain-text reports.
This order matters because every step has its own clear indication of success or failure, with no guessing. I have done it out of order and ended up unable to tell whether the problem was the deployment, the bot check, or the email provider, which made troubleshooting several times harder.
Conclusion
This entire system is one JavaScript file of about seventy lines, plus a few dozen lines of HTML in the modal. No additional server, no database, nothing to renew, and the cost is still zero.
I think the two most reusable lessons are these. First, a bot check that is not verified on the server does not block anything. It only blocks people using the form as designed, who are not the group I am worried about. Second, test API calls from where they will actually run, not from whichever machine is most convenient, because the provider's blocking rules look at the source of the request as well as the key.
Feel free to use the functions folder instead of a single file. With many routes, it really is easier to read. Just check the deployment logs every time to make sure it actually compiled successfully.
P.S. One thing I have not implemented this time is a per-IP hourly rate limit. Right now, I rely on the bot check alone, which is enough for this site's traffic. But if someone who can actually pass the check starts sending frequent requests, I will need somewhere at the edge to store a counter. I will come back and add that later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first