<?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 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>
