Features & Build Notes · · 3 min read
Building an HTMX contact form on Cloudflare Workers — no SPA
Full documentation of a no-SPA contact form: HTMX partial swaps, server validation with Hono JSX, input preserved on errors, and progressive enhancement — with this site's source code.
The contact form on this site uses no SPA at all: HTMX sends the POST, the server re-renders an HTML partial, and the page never reloads. Validation happens on the server (the only place you can trust), and the visitor’s input survives every error. This is the full documentation of how it’s built on Cloudflare Workers with Hono JSX.
The architecture
Browser ──POST (hx-post)──▶ Worker (Hono)
▲ │ server-side validation
└──── HTML partial ◀─────────┘ re-render form (errors) / success card
One endpoint, three possible responses: the form with error messages (422), a success card (200), or a delivery error (500). HTMX swaps whatever comes back into #contact-form-wrap.
1. The form with HTMX attributes
Form state lives in one interface — input values plus an error map — so the form can re-render with the visitor’s input intact:
export interface ContactForm {
name: string;
email: string;
message: string;
// ...other fields
errors: Record<string, string>;
}
export const ContactFormView = ({ f, locale }: { f: ContactForm; locale: Locale }) => (
<form hx-post={href(locale, "/contact")} hx-target="#contact-form-wrap"
hx-swap="outerHTML" class="space-y-6" novalidate>
{/* each Field renders f.errors[field] under its input */}
</form>
);
novalidate is deliberate: browser validation is disabled so every error message comes from the server — consistent across browsers, and still working without JavaScript.
2. The POST handler in the Worker
contactRoutes.post(path, async (c) => {
const form = await c.req.parseBody();
const f: ContactForm = {
name: val("name").trim(),
email: val("email").trim(),
message: val("message").trim(),
errors: {},
};
f.errors = validateContact(f, locale);
if (Object.keys(f.errors).length > 0) {
return c.html(<ContactFormView f={f} locale={locale} />, 422); // input intact + errors
}
await sendContact(c.env, f); // Resend HTTP API (log-only in dev)
return c.html(<ContactSuccess f={f} locale={locale} />);
});
Note what comes back: not JSON, but finished HTML. No state management in the browser, no useState, no duplicated validation logic.
3. Server-side validation
function validateContact(f: ContactForm, locale: Locale): Record<string, string> {
const msgs = resolveContact(locale).errors; // error copy follows the page language
const errs: Record<string, string> = {};
if (f.name === "") errs.name = msgs.name;
if (!EMAIL_RE.test(f.email)) errs.email = msgs.emailInvalid;
if (f.message.length < 10) errs.message = msgs.message;
return errs;
}
Why I use this pattern
- Light: HTMX is 14KB vs hundreds of KB for an SPA framework — on 4G you feel the difference.
- One source of truth: validation is written once, on the server.
- Progressive enhancement: without JavaScript, the form still submits as a plain POST.
- A natural fit for Workers: small HTML responses, zero cold start, no client build step.
Want something like this built for your business?