<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tilek Kubanov</title>
    <description>The latest articles on DEV Community by Tilek Kubanov (@uki7991).</description>
    <link>https://dev.to/uki7991</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4111637%2F9a8a64d7-60b3-439f-9adf-724c433d1fc9.png</url>
      <title>DEV Community: Tilek Kubanov</title>
      <link>https://dev.to/uki7991</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/uki7991"/>
    <language>en</language>
    <item>
      <title>How to send HTML form data to an email address</title>
      <dc:creator>Tilek Kubanov</dc:creator>
      <pubDate>Thu, 10 Sep 2026 23:20:14 +0000</pubDate>
      <link>https://dev.to/uki7991/how-to-send-html-form-data-to-an-email-address-a7k</link>
      <guid>https://dev.to/uki7991/how-to-send-html-form-data-to-an-email-address-a7k</guid>
      <description>&lt;p&gt;You have a static page with a contact form. You want what people write in it to arrive in your inbox. You search, and half the answers say &lt;code&gt;mailto:&lt;/code&gt;, the other half say "use a backend", and nobody tells you which details will bite you at 2am when a customer says they wrote and you never got it.&lt;/p&gt;

&lt;p&gt;Here is the whole picture: why HTML alone can't do this, the three options that actually work, and the failure modes each one has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why plain HTML can't send email
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; element does exactly one thing: it takes the fields, encodes them, and makes an HTTP request to whatever is in &lt;code&gt;action&lt;/code&gt;. That's it. It has no idea what email is.&lt;/p&gt;

&lt;p&gt;Email is a different protocol — SMTP — spoken by mail servers that require authentication, and a browser cannot speak it. Something on a server has to sit between the HTTP request and the mail server. That "something" is the entire question; the HTML part is trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/handle-form"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Your email&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Message&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every option below is just a different answer to "what is at the other end of &lt;code&gt;action&lt;/code&gt;".&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;mailto:&lt;/code&gt; is not sending email
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- please don't --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"mailto:you@example.com"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't send anything. It asks the visitor's operating system to open their configured mail client with a draft. Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a machine with no mail client configured — a lot of them, especially anything where webmail is the norm — nothing happens at all. The visitor clicks Send, sees nothing, and leaves.&lt;/li&gt;
&lt;li&gt;If a client does open, the visitor still has to press send themselves. Most won't.&lt;/li&gt;
&lt;li&gt;The encoding is ugly and inconsistent across clients; multi-field forms arrive as unreadable soup.&lt;/li&gt;
&lt;li&gt;Your address sits in the page source in plain text, which is exactly what address-harvesting crawlers look for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;mailto:&lt;/code&gt; as a plain link ("email us at…") is fine. As a form target it's a broken submit button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: your own endpoint
&lt;/h2&gt;

&lt;p&gt;You write a small server-side handler and point &lt;code&gt;action&lt;/code&gt; at it. It validates the input and hands it to a mail provider's API (Resend, Postmark, SES, Mailgun) or to SMTP directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api/contact.js — a serverless function, but a PHP/Rails/Django handler is the same shape&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.resend.com/emails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Website &amp;lt;forms@yourdomain.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// your domain — always&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;you@yourdomain.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reply_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                        &lt;span class="c1"&gt;// the visitor goes here, not in `from`&lt;/span&gt;
      &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;New contact form submission&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;303&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/thanks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Choose this when&lt;/strong&gt; you already run a server or a serverless platform, and you want the data to stay entirely yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're taking on:&lt;/strong&gt; deliverability (SPF, DKIM, DMARC on your domain — skip these and your mail goes to spam), spam filtering, rate limiting, and the fact that a form which silently stopped working looks exactly like a form nobody used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: a form backend service
&lt;/h2&gt;

&lt;p&gt;You point &lt;code&gt;action&lt;/code&gt; at a service's URL. It stores the submission and emails you. No server, no build step, works from a plain &lt;code&gt;.html&lt;/code&gt; file on any host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://catchform.dev/f/YOUR_TOKEN"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Choose this when&lt;/strong&gt; the site is static and the form is not the product. You're paying someone to own the boring parts: deliverability, spam, retries, storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you're giving up:&lt;/strong&gt; submissions live in someone else's database, and you're one more service dependency deep. Disclosure: CatchForm is mine — the concrete numbers at the end of this post are its numbers, everything above and below is not about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: a hosted form builder
&lt;/h2&gt;

&lt;p&gt;Google Forms, Typeform and friends. You don't write the HTML at all; you embed theirs or link out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose this when&lt;/strong&gt; the form matters more than the page — long surveys, questionnaires, anything with logic and reporting. &lt;strong&gt;Don't&lt;/strong&gt; choose it for a contact form: an iframe you can't style, on someone else's domain, is a bad first impression on your own site.&lt;/p&gt;




&lt;p&gt;Whatever you picked, the following details are where these things break. They are the same for all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The details that bite
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Never put the visitor's address in &lt;code&gt;From:&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the single most common way self-built form mail ends up in spam. It feels natural to set &lt;code&gt;from: visitor@gmail.com&lt;/code&gt; so replies just work — but your server is not authorized to send as gmail.com. SPF and DKIM checks fail, and receiving servers either bin the message or reject it outright.&lt;/p&gt;

&lt;p&gt;The rule: &lt;strong&gt;&lt;code&gt;From:&lt;/code&gt; is always your own domain. The visitor goes in &lt;code&gt;Reply-To:&lt;/code&gt;.&lt;/strong&gt; You still hit Reply and answer them; the mail is authenticated properly on the way in.&lt;/p&gt;

&lt;h3&gt;
  
  
  A newline in a form field is a mail header
&lt;/h3&gt;

&lt;p&gt;If you build headers by concatenating raw input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;Reply-To&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; &amp;lt;what the visitor typed&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…then a visitor who types &lt;code&gt;me@example.com\nBcc: everyone@else.com&lt;/code&gt; just added a header. This is header injection, it's decades old, and it's still trivially reachable through contact forms. Validate the address (&lt;code&gt;filter_var&lt;/code&gt;, a real email validator, anything) and never interpolate raw field values into headers.&lt;/p&gt;

&lt;h3&gt;
  
  
  CORS: &lt;code&gt;fetch()&lt;/code&gt; and a native form submit are not the same request
&lt;/h3&gt;

&lt;p&gt;This one produces the most confusing bug report in the category: &lt;em&gt;"the form shows an error but the submissions arrive twice."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A native &lt;code&gt;&amp;lt;form method="POST"&amp;gt;&lt;/code&gt; submit is a page navigation. CORS does not apply to it — it works across domains, always, and it always has.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fetch()&lt;/code&gt; is subject to CORS, and the details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your body is &lt;code&gt;FormData&lt;/code&gt; or &lt;code&gt;URLSearchParams&lt;/code&gt;, it's a &lt;strong&gt;simple request&lt;/strong&gt; — no preflight. The browser sends it, your server receives and stores it, and &lt;em&gt;then&lt;/em&gt; the browser refuses to hand the response back to your JS because the &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header wasn't there. Your code shows "failed", the visitor submits again, you get duplicates.&lt;/li&gt;
&lt;li&gt;If you send &lt;code&gt;Content-Type: application/json&lt;/code&gt;, it's a &lt;strong&gt;preflighted request&lt;/strong&gt; — the browser sends &lt;code&gt;OPTIONS&lt;/code&gt; first. If the endpoint doesn't answer that, nothing is sent at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: a failed cross-origin &lt;code&gt;fetch&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; mean the submission was lost. Check the receiving side before you retry. And if you own the endpoint, answer &lt;code&gt;OPTIONS&lt;/code&gt; and set the headers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// ask for JSON instead of a redirect&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                 &lt;span class="c1"&gt;// let the browser set Content-Type&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showThanks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't set &lt;code&gt;Content-Type&lt;/code&gt; by hand for &lt;code&gt;FormData&lt;/code&gt; — you'll strip the multipart boundary and the server will parse nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decide what the response is before you build the form
&lt;/h3&gt;

&lt;p&gt;A native form submit needs somewhere to land: a redirect to a thank-you page (&lt;code&gt;303&lt;/code&gt;), or you leave the visitor staring at raw JSON. A &lt;code&gt;fetch&lt;/code&gt; submit needs a status code it can branch on. Pick one per form and make the endpoint honor it — usually via the &lt;code&gt;Accept&lt;/code&gt; header, as above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spam: honeypot first, CAPTCHA only if that fails
&lt;/h3&gt;

&lt;p&gt;A honeypot is a field a human never sees and a bot fills in anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"_gotcha"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;
       &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"position:absolute;left:-9999px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hide it with CSS, not &lt;code&gt;type="hidden"&lt;/code&gt; — plenty of bots skip hidden inputs. &lt;code&gt;tabindex="-1"&lt;/code&gt; keeps keyboard users out of it, &lt;code&gt;aria-hidden&lt;/code&gt; keeps screen readers out of it, &lt;code&gt;autocomplete="off"&lt;/code&gt; stops the browser from helpfully filling it in for a real person.&lt;/p&gt;

&lt;p&gt;Non-empty on arrival → drop it. This costs nothing and removes most of the volume. Reach for a CAPTCHA only when someone is targeting you specifically, because every CAPTCHA is a tax on the humans too.&lt;/p&gt;

&lt;h3&gt;
  
  
  File uploads need &lt;code&gt;enctype&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;enctype=&lt;/span&gt;&lt;span class="s"&gt;"multipart/form-data"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"file"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"attachment"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;enctype="multipart/form-data"&lt;/code&gt; the browser sends the file &lt;em&gt;name&lt;/em&gt; and not the file. Check the size limit on both ends — the receiving side's limit and, if you're hosting the handler, the web server's own body-size cap, which is usually the smaller of the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete endpoint, end to end
&lt;/h2&gt;

&lt;p&gt;To make the above less abstract, here is exactly how the receiving side behaves in CatchForm (my service, as disclosed above) — the shape is representative of the category, the numbers are its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"https://catchform.dev/f/YOUR_TOKEN"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;
      &lt;span class="na"&gt;enctype=&lt;/span&gt;&lt;span class="s"&gt;"multipart/form-data"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"_gotcha"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;
         &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"position:absolute;left:-9999px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The response.&lt;/strong&gt; With no redirect configured on the form, you get &lt;code&gt;200&lt;/code&gt; and &lt;code&gt;{"success": true}&lt;/code&gt; — the JSON that a &lt;code&gt;fetch&lt;/code&gt; handler wants. Configure a redirect URL and the same endpoint answers &lt;code&gt;302&lt;/code&gt; to it instead, which is what a native form submit wants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-origin is the normal case.&lt;/strong&gt; The endpoint exists to accept POSTs from domains it has never seen, so &lt;code&gt;OPTIONS&lt;/code&gt; is answered with &lt;code&gt;204&lt;/code&gt; and &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;. No cookies, no credentials — the token in the URL is the entire mandate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spam&lt;/strong&gt; — a non-empty &lt;code&gt;_gotcha&lt;/code&gt; gets &lt;code&gt;422&lt;/code&gt; and &lt;code&gt;{"success": false, "message": "Spam detected."}&lt;/code&gt;, and nothing is stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over quota&lt;/strong&gt; — &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;{"error": "Monthly limit exceeded"}&lt;/code&gt;. Note that this is a distinct code from spam, on purpose: one is the visitor's problem and one is yours, and a form that fails silently teaches you nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply-To&lt;/strong&gt; is taken from an &lt;code&gt;email&lt;/code&gt; field in the submission if it parses as an address — so replying from your inbox reaches the person who wrote, while &lt;code&gt;From:&lt;/code&gt; stays on an authenticated domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field names starting with &lt;code&gt;_&lt;/code&gt; are control fields&lt;/strong&gt; and are never stored with the submission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files&lt;/strong&gt; are capped at 5 MB each and limited to &lt;code&gt;jpeg&lt;/code&gt;, &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;gif&lt;/code&gt;, &lt;code&gt;pdf&lt;/code&gt;. On the free plan a submission with an attachment is still accepted and stored — the text is never thrown away because of a plan limit — but the file itself isn't kept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits:&lt;/strong&gt; free is 1 form and 100 submissions a month; Pro is $12/month (or $120/year) for 10 forms, 10 000 submissions, stored attachments and webhooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Picking one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Static site, contact form, don't want to think about it again → a form backend.&lt;/li&gt;
&lt;li&gt;Already have a server or a serverless platform, and want the data to stay yours → your own endpoint, and budget an afternoon for SPF/DKIM.&lt;/li&gt;
&lt;li&gt;A survey rather than a contact form → a form builder, linked to rather than embedded.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mailto:&lt;/code&gt; → no.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, test it the boring way: submit the real form from a phone on mobile data, and check the spam folder of the address you're sending &lt;em&gt;to&lt;/em&gt;. Most broken contact forms were tested exactly once, from the developer's own machine, on the day they were built.&lt;/p&gt;

</description>
      <category>html</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to add a contact form to an Astro site without a backend</title>
      <dc:creator>Tilek Kubanov</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:00:33 +0000</pubDate>
      <link>https://dev.to/uki7991/how-to-add-a-contact-form-to-an-astro-site-without-a-backend-31od</link>
      <guid>https://dev.to/uki7991/how-to-add-a-contact-form-to-an-astro-site-without-a-backend-31od</guid>
      <description>&lt;p&gt;You build an Astro site. It's fast, it's static, it deploys anywhere. Then someone asks for a contact form and you remember that &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; needs somewhere to POST — and you don't have a server.&lt;/p&gt;

&lt;p&gt;Here are the four options that actually exist, when each one is right, and the details nobody mentions until they bite you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: &lt;code&gt;mailto:&lt;/code&gt; — don't
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"mailto:you@example.com"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It seems to work on your machine. In the wild it opens whatever the visitor's browser thinks is their mail client, which for most people is nothing at all. They see a broken page or a download prompt, and you never learn that they tried. It also publishes your address to every scraper on the internet.&lt;/p&gt;

&lt;p&gt;Skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Astro SSR with an API route
&lt;/h2&gt;

&lt;p&gt;Astro can run on a server. Add an adapter, switch the route to server-rendered, and you can handle the POST yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/pages/api/contact.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prerender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// ...validate, then send mail with your provider of choice&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;you@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;New contact form submission&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;303&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/thanks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;When this is right:&lt;/strong&gt; you already run the site with an adapter, and you want the submission to touch your own database or business logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt; a host that runs a server, an email provider account and its API key, deliverability setup (SPF, DKIM, DMARC — get these wrong and your mail silently lands in spam), spam filtering, and somewhere to store submissions when the mail fails. That's a weekend, not an afternoon, and it's a weekend you spend again on the next site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: A serverless function
&lt;/h2&gt;

&lt;p&gt;Netlify Functions, Vercel Functions, Cloudflare Workers. Same code as above, no server to keep alive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is right:&lt;/strong&gt; you're already on that platform and comfortable there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt; everything from option 2 except the server, plus a tie to that platform. Move the site to a different host and the form stops working with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: A hosted form backend
&lt;/h2&gt;

&lt;p&gt;Point the form's &lt;code&gt;action&lt;/code&gt; at someone else's URL. They receive the POST, store it, email you, and give you an inbox. No server, no API key, no deliverability setup.&lt;/p&gt;

&lt;p&gt;There are plenty: &lt;a href="https://formspree.io" rel="noopener noreferrer"&gt;Formspree&lt;/a&gt;, &lt;a href="https://usebasin.com" rel="noopener noreferrer"&gt;Basin&lt;/a&gt;, &lt;a href="https://formcarry.com" rel="noopener noreferrer"&gt;Formcarry&lt;/a&gt;, &lt;a href="https://web3forms.com" rel="noopener noreferrer"&gt;Web3Forms&lt;/a&gt;, &lt;a href="https://docs.netlify.com/manage/forms/setup/" rel="noopener noreferrer"&gt;Netlify Forms&lt;/a&gt; if you're on Netlify. I build &lt;a href="https://catchform.dev" rel="noopener noreferrer"&gt;CatchForm&lt;/a&gt;, so that's what the code below uses — the shape is the same whichever you pick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When this is right:&lt;/strong&gt; the form is a contact form. If submissions just need to reach a human, running infrastructure for that is a hobby, not a requirement.&lt;/p&gt;




&lt;p&gt;The rest of this post is option 4 done properly in Astro.&lt;/p&gt;

&lt;h2&gt;
  
  
  The component
&lt;/h2&gt;

&lt;p&gt;Start with the version that works when JavaScript doesn't. Astro is good at this — no client-side JS at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// src/components/ContactForm.astro
const endpoint = `https://catchform.dev/f/${import.meta.env.PUBLIC_FORM_TOKEN}`;
---

&amp;lt;form action={endpoint} method="POST" class="contact"&amp;gt;
  &amp;lt;!-- Honeypot. Real people never fill a hidden field; bots fill everything.
       Keep it empty and unlabelled, and keep it out of the tab order. --&amp;gt;
  &amp;lt;input type="text" name="_gotcha" tabindex="-1" autocomplete="off" hidden /&amp;gt;

  &amp;lt;label for="email"&amp;gt;Your email&amp;lt;/label&amp;gt;
  &amp;lt;input id="email" type="email" name="email" required autocomplete="email" /&amp;gt;

  &amp;lt;label for="message"&amp;gt;Message&amp;lt;/label&amp;gt;
  &amp;lt;textarea id="message" name="message" rows="6" required&amp;gt;&amp;lt;/textarea&amp;gt;

  &amp;lt;button type="submit"&amp;gt;Send&amp;lt;/button&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a working contact form. The browser posts it, the backend stores it, you get an email. It works with JS disabled, on a slow phone, in a text browser.&lt;/p&gt;

&lt;p&gt;Three things worth noticing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token goes in the client.&lt;/strong&gt; It has to — the browser is doing the POST. &lt;code&gt;PUBLIC_&lt;/code&gt; is Astro's marker for exactly this. The token identifies which form the submission belongs to; it isn't a secret and can't read anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field names are yours.&lt;/strong&gt; There's no schema to configure. Whatever your HTML sends is what gets stored, so renaming &lt;code&gt;message&lt;/code&gt; to &lt;code&gt;enquiry&lt;/code&gt; needs no change anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honeypot is a real input, not a comment.&lt;/strong&gt; &lt;code&gt;hidden&lt;/code&gt; keeps it off the screen, &lt;code&gt;tabindex="-1"&lt;/code&gt; keeps keyboard users out of it, &lt;code&gt;autocomplete="off"&lt;/code&gt; keeps the browser from helpfully filling it in — that last one matters, because a password manager that fills every field will get your visitor's message rejected as spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upgrading to &lt;code&gt;fetch()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Full page navigation is fine, but you probably want to keep the visitor on the page. Add JS as an enhancement, so the form still works without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;script&amp;gt;
  const form = document.querySelector&amp;lt;HTMLFormElement&amp;gt;('.contact');
  const status = document.querySelector&amp;lt;HTMLParagraphElement&amp;gt;('#form-status');

  form?.addEventListener('submit', async (event) =&amp;gt; {
    event.preventDefault();

    const button = form.querySelector('button');
    button.disabled = true;
    status.textContent = 'Sending…';

    try {
      const response = await fetch(form.action, {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: new FormData(form),
      });

      // 422 means the honeypot caught it. Show the same thank-you: telling a
      // bot it was rejected only teaches it what to change. If real people are
      // hitting this, your honeypot is being autofilled — see autocomplete="off".
      if (response.ok || response.status === 422) {
        form.reset();
        status.textContent = 'Thanks — I will get back to you.';
      } else {
        const body = await response.json().catch(() =&amp;gt; ({}));
        status.textContent = body.error ?? 'Something went wrong. Please try again.';
      }
    } catch {
      status.textContent = 'Could not reach the server. Please try again.';
    } finally {
      button.disabled = false;
    }
  });
&amp;lt;/script&amp;gt;

&amp;lt;p id="form-status" role="status" aria-live="polite"&amp;gt;&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;role="status"&lt;/code&gt; with &lt;code&gt;aria-live="polite"&lt;/code&gt; matters more than it looks: without it a screen reader user gets no feedback at all, because visually the only thing that changed is a paragraph they never focus.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gotcha that will cost you an hour
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;If your form has a redirect URL configured, &lt;code&gt;fetch()&lt;/code&gt; won't see JSON.&lt;/strong&gt; The backend answers a successful submission with a 302 to that URL, &lt;code&gt;fetch&lt;/code&gt; follows it, and &lt;code&gt;response.ok&lt;/code&gt; is true for a page you never wanted. You get JSON only when no redirect is configured.&lt;/p&gt;

&lt;p&gt;So pick one, deliberately: a redirect URL for the no-JavaScript flow, or an empty redirect and JSON for the &lt;code&gt;fetch&lt;/code&gt; flow. If you want both — the plain form as a fallback and JS on top — leave the redirect empty and handle the success state yourself in both paths, with the &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;/code&gt; case landing on the JSON response.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other one
&lt;/h3&gt;

&lt;p&gt;Check that your backend actually sends CORS headers. A cross-origin &lt;code&gt;fetch&lt;/code&gt; needs &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; on the response, and a simple form POST doesn't. If they're missing, the failure is nasty: the request goes through and your submission is stored, but the browser refuses to let JS read the response, so your visitor sees an error and sends it again. You get duplicates and nobody understands why.&lt;/p&gt;

&lt;p&gt;Test it before you ship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; OPTIONS https://your-backend.example/your-endpoint &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Origin: https://yoursite.example"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Access-Control-Request-Method: POST"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;access-control-allow-origin&lt;/code&gt; in the output. (I found this missing in my own service while writing this post. It's fixed now — but check yours.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the response
&lt;/h2&gt;

&lt;p&gt;Whatever backend you use, decide what happens on each outcome before your visitors find out for you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Typical response&lt;/th&gt;
&lt;th&gt;What the visitor should see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; with a JSON body&lt;/td&gt;
&lt;td&gt;a thank-you, and a cleared form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caught as spam&lt;/td&gt;
&lt;td&gt;&lt;code&gt;422&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same thank-you — never tell a bot it was caught&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over the monthly quota&lt;/td&gt;
&lt;td&gt;&lt;code&gt;429&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"we could not accept this right now", and an email to you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bad or inactive token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;404&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a generic failure, plus an alert to yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third row is the one people skip. On a free plan the quota is not theoretical, and a form that silently stops accepting messages is worse than no form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to put the token
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;PUBLIC_FORM_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/env.d.ts — so a typo becomes a build error, not a broken form&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ImportMetaEnv&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;PUBLIC_FORM_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And add the variable to your host's build settings. Forgetting that is the single most common way this breaks in production: &lt;code&gt;import.meta.env.PUBLIC_FORM_TOKEN&lt;/code&gt; becomes &lt;code&gt;undefined&lt;/code&gt;, the action becomes &lt;code&gt;.../f/undefined&lt;/code&gt;, and every submission 404s. Guard it if you like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
const token = import.meta.env.PUBLIC_FORM_TOKEN;
if (!token) throw new Error('PUBLIC_FORM_TOKEN is not set');
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An Astro build that fails loudly beats a deployed form that fails quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option should you pick
&lt;/h2&gt;

&lt;p&gt;If the form's job is to get a message to a person: option 4, and spend the afternoon on something else.&lt;/p&gt;

&lt;p&gt;If the submission has to touch your own data — create a record, check stock, trigger a workflow — write the endpoint yourself. Options 2 and 3 exist for that, and a form backend would only be in the way.&lt;/p&gt;

&lt;p&gt;If you're already on Netlify and never plan to leave: Netlify Forms is right there and costs you nothing extra to try.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://catchform.dev" rel="noopener noreferrer"&gt;CatchForm&lt;/a&gt;, the form backend used in the examples — free plan is 1 form and 100 submissions a month, no card. The Astro-specific version of this guide lives &lt;a href="https://catchform.dev/guides/contact-form-astro" rel="noopener noreferrer"&gt;here&lt;/a&gt;. Happy to answer questions about any of the four options in the comments, including the ones that don't involve me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>webdev</category>
      <category>jamstack</category>
      <category>html</category>
    </item>
  </channel>
</rss>
