<?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: Mathias Ahlgren</title>
    <description>The latest articles on DEV Community by Mathias Ahlgren (@mathiasahlgren).</description>
    <link>https://dev.to/mathiasahlgren</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%2F1690379%2F65c596a4-6bd1-4338-8dfc-68db3bdd1682.jpg</url>
      <title>DEV Community: Mathias Ahlgren</title>
      <link>https://dev.to/mathiasahlgren</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mathiasahlgren"/>
    <language>en</language>
    <item>
      <title>How to Replace a Google Form With a Real HTML Form on Your Site</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:56:00 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/how-to-replace-a-google-form-with-a-real-html-form-on-your-site-5hh4</link>
      <guid>https://dev.to/mathiasahlgren/how-to-replace-a-google-form-with-a-real-html-form-on-your-site-5hh4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Most guides about Google Forms and your website answer a question you did not ask.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Search for how to replace a Google Form with your own HTML and you get three kinds of answer. Embed the iframe but style the container. Use a service that hides Google's branding. Or the clever one: build your own HTML form and point it at Google's endpoint, so responses still land in your existing spreadsheet.&lt;/p&gt;

&lt;p&gt;All three keep Google Forms in the loop. If that is what you want, they work, and I will show you the third one because it is genuinely useful when you need it.&lt;/p&gt;

&lt;p&gt;But if you actually want the Google Form gone, replaced by markup you own, here is how that works and what it costs you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-line summary:&lt;/strong&gt; Google Forms does one thing your static site can't, accept a POST; swap that for a form endpoint and you get your markup back, at the cost of owning spam and losing free-unlimited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the iframe is the problem
&lt;/h2&gt;

&lt;p&gt;The embed is an iframe. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You cannot restyle it. Your fonts and colours stop at the border.&lt;/li&gt;
&lt;li&gt;It does not resize with its content, so a long form becomes a scroll area inside your page.&lt;/li&gt;
&lt;li&gt;It looks like Google on your site, because it is.&lt;/li&gt;
&lt;li&gt;You inherit its accessibility behaviour and can do nothing about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that matters for an internal survey or a sports club sign-up sheet. It matters a lot on a business site, where a Google-branded iframe reads as a stopgap someone never got round to replacing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clever workaround, and where it breaks
&lt;/h2&gt;

&lt;p&gt;You can POST your own HTML form straight at a Google Form's response endpoint. Open your form, inspect the page, dig the field IDs out of the markup, and build a form whose input names match:&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://docs.google.com/forms/d/e/YOUR_FORM_ID/formResponse"&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;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"entry.1234567890"&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;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;"entry.9876543210"&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;Responses land in the same spreadsheet. No new service. For a throwaway internal page, this is fine.&lt;/p&gt;

&lt;p&gt;The guides that recommend it tend to stop there, so here is the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The field names are opaque and fragile.&lt;/strong&gt; &lt;code&gt;entry.1234567890&lt;/code&gt; means nothing to the next person reading your template, and it is tied to a form you now have to keep alive in someone's Google Drive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot read the response.&lt;/strong&gt; Google does not send CORS headers for this endpoint, so a &lt;code&gt;fetch&lt;/code&gt; either fails or you send it with &lt;code&gt;mode: "no-cors"&lt;/code&gt; and get an opaque response back. You cannot tell success from failure. Submitting the form natively navigates the user to a Google-branded confirmation page you do not control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no server-side validation you own.&lt;/strong&gt; Whatever Google's form does with a malformed submission is what happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing tells you when it breaks.&lt;/strong&gt; If the form is deleted, renamed, or its fields change, your site keeps posting into the void and you find out when someone asks why you never replied.&lt;/p&gt;

&lt;p&gt;It is a workaround. Treat it as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replacing it properly
&lt;/h2&gt;

&lt;p&gt;A real HTML form needs somewhere to POST. That is the only thing Google Forms was actually providing that a static site cannot do alone.&lt;/p&gt;

&lt;p&gt;The markup is ordinary:&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://api.formpaste.com/submit"&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;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_ACCESS_KEY"&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;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Your 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;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Your 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;That is a real form. Your CSS applies. Your validation applies. The field names are the field names. Submissions arrive by email.&lt;/p&gt;

&lt;p&gt;I work on &lt;a href="https://formpaste.com" rel="noopener noreferrer"&gt;Formpaste&lt;/a&gt;, which is what that endpoint is, so weigh the recommendation accordingly. &lt;strong&gt;Formspree&lt;/strong&gt;, &lt;strong&gt;Web3Forms&lt;/strong&gt; and &lt;strong&gt;Basin&lt;/strong&gt; do the same job and the rest of this post applies to all of them. What matters is the shape: your markup, someone else's endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sending people somewhere after submit
&lt;/h3&gt;

&lt;p&gt;By default the browser posts and shows a response. If you want your own thank-you page, add a redirect field:&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;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"redirect"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/thanks"&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;Worth knowing that any endpoint accepting a redirect target has to validate it, otherwise it is an open redirect that spammers will find and use to launder links through your domain. Formpaste checks it against the form's allowlisted domains and rejects anything else. If you are evaluating another service, that is a fair thing to ask about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the spreadsheet
&lt;/h3&gt;

&lt;p&gt;This is usually the real objection. The Google Form was feeding a Sheet, and something downstream reads that Sheet.&lt;/p&gt;

&lt;p&gt;You have two options. Export the existing responses to CSV and keep them as an archive, which costs nothing and takes a minute. Or keep new submissions syncing into a Sheet automatically, which most form backends offer as a paid integration. On Formpaste it is a Pro feature, not a free one, and I would rather say that here than have you find out at the paywall. It appends a timestamped row per submission and adds a column when a new field shows up rather than silently dropping the value.&lt;/p&gt;

&lt;p&gt;If the spreadsheet is load-bearing for you, price that in before you migrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spam, which Google was quietly handling
&lt;/h2&gt;

&lt;p&gt;Google Forms is not much of a spam target because the endpoint is obscure and reCAPTCHA is one toggle away. A public form endpoint on your own domain is a different proposition. Once you move, you own this problem.&lt;/p&gt;

&lt;p&gt;The usual answer is a CAPTCHA. It works, and it makes every real visitor solve a puzzle to talk to you, which on a contact form is a strange trade.&lt;/p&gt;

&lt;p&gt;The alternative is scoring submissions on signals the visitor never sees: a honeypot field bots fill and humans do not, how fast the form was completed, submission rate from one source, disposable-email domains. Anything borderline goes to a review folder instead of being deleted, so a false positive is recoverable rather than a silently lost message.&lt;/p&gt;

&lt;p&gt;Whatever you pick, decide it deliberately. A brand-new public endpoint with no spam handling gets found.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to keep Google Forms
&lt;/h2&gt;

&lt;p&gt;Genuinely, sometimes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-technical people need to edit the form.&lt;/strong&gt; &lt;a href="https://support.google.com/a/users/answer/9303071?hl=en" rel="noopener noreferrer"&gt;Google Forms&lt;/a&gt; has a UI for that. A hand-written HTML form means editing markup and deploying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need quizzes, branching, or conditional logic.&lt;/strong&gt; That is a survey tool's job. Rebuilding it in raw HTML is work you do not want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a one-off.&lt;/strong&gt; An event RSVP that runs for three weeks does not need a migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget is zero and volume is high.&lt;/strong&gt; Google Forms is free and effectively unlimited. Hosted form backends have free tiers with monthly caps. Formpaste's free plan is 250 submissions a month across 3 forms; others differ. If you are collecting thousands of responses and do not care how it looks, Google wins on price.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rebuild when the form is a permanent part of a site you control the design of, and the people maintaining it are comfortable in a code editor. That is the whole rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration, start to finish
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Export the existing responses from the linked Sheet. Keep the file.&lt;/li&gt;
&lt;li&gt;Write the HTML form. Same fields, real names, your markup.&lt;/li&gt;
&lt;li&gt;Point it at an endpoint and confirm a test submission arrives.&lt;/li&gt;
&lt;li&gt;Style it. This is the part you could not do before.&lt;/li&gt;
&lt;li&gt;Add a redirect to your own thank-you page.&lt;/li&gt;
&lt;li&gt;Decide on spam handling before you make it public, not after.&lt;/li&gt;
&lt;li&gt;Replace the iframe. Leave the old Google Form in place, closed to responses, until you are sure.&lt;/li&gt;
&lt;li&gt;If anything downstream read that Sheet, either point it at the archive or set up a sync.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 7 is the one people skip. Keep the old form until you have seen real submissions arrive through the new one.&lt;/p&gt;

&lt;p&gt;If you are still deciding whether to rebuild at all, I wrote about that trade-off separately: &lt;a href="https://formpaste.com/blog/google-form-on-your-own-website/" rel="noopener noreferrer"&gt;Putting a Google Form on Your Own Website (And When to Rebuild It Instead)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>forms</category>
      <category>googleforms</category>
    </item>
    <item>
      <title>How to Add a Contact Form to a Static HTML Site (Without a Backend)</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Sun, 26 Jul 2026 12:01:02 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/how-to-add-a-contact-form-to-a-static-html-site-without-a-backend-195m</link>
      <guid>https://dev.to/mathiasahlgren/how-to-add-a-contact-form-to-a-static-html-site-without-a-backend-195m</guid>
      <description>&lt;p&gt;&lt;strong&gt;Static sites are fantastic: fast, cheap to host, secure, and dead simple to deploy. Then a client says "can visitors email us from the site?" and suddenly you're staring at the one thing a static site can't do on its own: receive a form submission.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The good news is you don't need a backend or server to do this. By the end of this guide you'll have a fully working, accessible, spam-resistant contact form you can drop into plain HTML, React, Next.js, Vue, Astro, or Svelte, with complete copy-paste code, not placeholders.&lt;/p&gt;

&lt;p&gt;Let's start by being honest about your options.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full disclosure
&lt;/h3&gt;

&lt;p&gt;I work with &lt;a href="https://formpaste.com/" rel="noopener noreferrer"&gt;Formpaste, one of the form-backend services covered below&lt;/a&gt;, so I'll use it in the hands-on examples. I've kept the comparison section genuinely even-handed. The techniques here work with any provider, and I'll tell you where each one fits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options for handling forms without a backend
&lt;/h2&gt;

&lt;p&gt;There are really four common approaches, and they're not equal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;mailto:&lt;/code&gt; links.&lt;/strong&gt; Zero setup. &lt;code&gt;&amp;lt;a href="mailto:you@example.com"&amp;gt;&lt;/code&gt; opens the visitor's email client. But it's a rough experience: it depends on a configured desktop mail app, publishes your address for scrapers to harvest, and collects nothing structured. Fine for a personal landing page, not for a real contact form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Google Forms embed.&lt;/strong&gt; Free and no code, but the iframe looks nothing like your site, works against your design and performance, and the UX is clearly "this is a Google Form." Fine for a hobby project, weak for anything that needs to look professional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Host-native form handling.&lt;/strong&gt; Netlify Forms and a few other hosts offer built-in form capture. Genuinely nice, &lt;em&gt;if&lt;/em&gt; you're on that host. The moment you move to GitHub Pages, Cloudflare Pages, or your own S3 bucket, it's gone. You're locked in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A form-backend API.&lt;/strong&gt; You point your &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; at an endpoint; the service receives submissions, filters spam, and emails you (or fires a webhook). Host-agnostic, works with any framework, and keeps your markup 100% yours. This is the approach that works everywhere, so it's what we'll build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The HTML form (accessible by default)
&lt;/h2&gt;

&lt;p&gt;Here's a complete, accessible form. Note the real &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; elements and the &lt;code&gt;for&lt;/code&gt;/&lt;code&gt;id&lt;/code&gt; pairing, which is the part most tutorials skip.&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://api.formpaste.com/submit"&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;id=&lt;/span&gt;&lt;span class="s"&gt;"contact-form"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Your access key from the dashboard --&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;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_ACCESS_KEY_HERE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;div&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;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Name&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;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"name"&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;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;div&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;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;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&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;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&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;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;div&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;rows=&lt;/span&gt;&lt;span class="s"&gt;"5"&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;/div&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 message&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;That's a working form already. With no JavaScript at all, a submission POSTs to the endpoint and lands in your inbox. Progressive enhancement done right: it works before a single line of JS runs.&lt;/p&gt;

&lt;p&gt;By default the endpoint answers with JSON. Without JavaScript on the page the browser will simply display that JSON response, which is not the experience you want, so see Step 4 for how to send the visitor to a thank-you page instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Enhance it with JavaScript (proper loading and error states)
&lt;/h2&gt;

&lt;p&gt;The plain form causes a full page navigation. To keep users on the page with inline feedback, enhance it. Notice this actually selects the form by its &lt;code&gt;id&lt;/code&gt;, handles the network failure case, and gives real status feedback, which are the details that separate a working form from a demo.&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://api.formpaste.com/submit"&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;id=&lt;/span&gt;&lt;span class="s"&gt;"contact-form"&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;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_ACCESS_KEY_HERE"&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;"text"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&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;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;id=&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;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 message&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form-status"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact-form&lt;/span&gt;&lt;span class="dl"&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;statusEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form-status&lt;/span&gt;&lt;span class="dl"&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;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"]&lt;/span&gt;&lt;span class="dl"&gt;'&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="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="s2"&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="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sending…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;try&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;response&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="s2"&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;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="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;response&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thanks! Your message was sent.&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// The API returns { success: false, code, message } on failure.&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;response&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="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Please try again.&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;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;statusEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Network error. Please try again.&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;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;aria-live="polite"&lt;/code&gt; region means screen readers announce the status change, which is a free accessibility win.&lt;/p&gt;

&lt;p&gt;A successful response is JSON shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Submission received."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Errors use the same envelope with a machine-readable code, so you can branch on &lt;code&gt;data.code&lt;/code&gt; if you want distinct messages for &lt;code&gt;rate_limited&lt;/code&gt; versus &lt;code&gt;domain_not_allowed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Framework examples (complete, not placeholders)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  React
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ContactForm&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// idle | sending | success | error&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;handleSubmit&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="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="c1"&gt;// Capture the form node now: after the first await, `currentTarget` is null.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;try&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;response&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.formpaste.com/submit&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="s2"&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;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="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&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;response&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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&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;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_ACCESS_KEY_HERE"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="na"&gt;htmlFor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Name&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="na"&gt;htmlFor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Email&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

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

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&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="s2"&gt;Sending…&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="s2"&gt;Send message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Thanks! Your message was sent.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Something went wrong. Try again.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;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;h3&gt;
  
  
  Next.js (App Router)
&lt;/h3&gt;

&lt;p&gt;With the App Router, keep the form as a client component so you can manage submit state. You don't need a route handler at all, since the form posts directly to the API.&lt;/p&gt;

&lt;p&gt;Worth saying plainly: the access key sits in client-side code and is visible to anyone who views source. That's by design for this kind of endpoint. The key identifies which form a submission belongs to; it isn't a secret credential, and it can't be used to read your submissions. Your protection against someone reusing it elsewhere is the domain allowlist, covered below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ContactForm&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&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="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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// capture before awaiting&lt;/span&gt;
    &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;try&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.formpaste.com/submit&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="s2"&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;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="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&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;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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&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;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_ACCESS_KEY_HERE"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Message"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&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="s2"&gt;Sending…&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="s2"&gt;Send message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Message sent!&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Please try again.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;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;h3&gt;
  
  
  Vue 3
&lt;/h3&gt;

&lt;p&gt;This one posts JSON instead of &lt;code&gt;FormData&lt;/code&gt;, just to show the endpoint accepts both. Because the values come from &lt;code&gt;v-model&lt;/code&gt; state, the access key goes in the JSON body and there's no hidden input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;submit.prevent=&lt;/span&gt;&lt;span class="s"&gt;"submitForm"&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;"name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Name&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;"name"&lt;/span&gt; &lt;span class="na"&gt;v-model=&lt;/span&gt;&lt;span class="s"&gt;"form.name"&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;"name"&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;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;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;v-model=&lt;/span&gt;&lt;span class="s"&gt;"form.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;v-model=&lt;/span&gt;&lt;span class="s"&gt;"form.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;&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="na"&gt;:disabled=&lt;/span&gt;&lt;span class="s"&gt;"status === 'sending'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&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="s2"&gt;Sending…&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="s2"&gt;Send message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"status === 'success'"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Thanks! Message sent.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"status === 'error'"&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Something went wrong.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&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;ACCESS_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_ACCESS_KEY_HERE&lt;/span&gt;&lt;span class="dl"&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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.formpaste.com/submit&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="s2"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&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="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="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="na"&gt;access_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ACCESS_KEY&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&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;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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&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;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Astro
&lt;/h3&gt;

&lt;p&gt;Astro ships zero JS by default, so the plain HTML form works as-is in a &lt;code&gt;.astro&lt;/code&gt; file.&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 ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
---

&amp;lt;form action="https://api.formpaste.com/submit" method="POST" id="contact-form"&amp;gt;
  &amp;lt;input type="hidden" name="access_key" value={ACCESS_KEY} /&amp;gt;
  &amp;lt;input type="hidden" name="redirect" value="/thank-you" /&amp;gt;

  &amp;lt;label for="name"&amp;gt;Name&amp;lt;/label&amp;gt;
  &amp;lt;input id="name" type="text" name="name" required /&amp;gt;

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

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

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

&lt;/div&gt;



&lt;p&gt;Because there's no JavaScript here, the &lt;code&gt;redirect&lt;/code&gt; field matters: without it the browser would land on the raw JSON response. If you'd rather have inline feedback, drop the &lt;code&gt;redirect&lt;/code&gt; field and add a client script:&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.getElementById("contact-form");

  form.addEventListener("submit", async (e) =&amp;gt; {
    e.preventDefault();
    const res = await fetch(form.action, {
      method: "POST",
      body: new FormData(form),
    });
    form.insertAdjacentHTML(
      "beforeend",
      res.ok ? "&amp;lt;p role='status'&amp;gt;Message sent!&amp;lt;/p&amp;gt;" : "&amp;lt;p role='status'&amp;gt;Please try again.&amp;lt;/p&amp;gt;",
    );
    if (res.ok) form.reset();
  });
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Svelte
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// capture before awaiting&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.formpaste.com/submit&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="s2"&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;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="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&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="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&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;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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&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;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;on:submit&lt;/span&gt;&lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="na"&gt;preventDefault=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&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;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"access_key"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"YOUR_ACCESS_KEY_HERE"&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;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Name"&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;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;placeholder=&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;placeholder=&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="na"&gt;disabled=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sending&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="s2"&gt;Sending…&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="s2"&gt;Send message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;#if&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Message sent!&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;/if&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;#if&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Please try again.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;/if&lt;/span&gt;&lt;span class="si"&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;h2&gt;
  
  
  Step 4: Redirect vs. AJAX, pick your success flow
&lt;/h2&gt;

&lt;p&gt;There are two ways to confirm a submission, and the choice is made by the request itself, not by a header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON (the default).&lt;/strong&gt; Post the form and you get &lt;code&gt;{ success, message, id }&lt;/code&gt; back with a 200. That's what every JavaScript example above relies on: intercept the submit, &lt;code&gt;fetch&lt;/code&gt;, show an inline message, never leave the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirect.&lt;/strong&gt; Add a &lt;code&gt;redirect&lt;/code&gt; field to the form and a successful submission answers with a &lt;code&gt;303 See Other&lt;/code&gt; to that URL, so the browser lands on your thank-you page. This is the no-JS path.&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;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"redirect"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"/thank-you"&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;Two rules worth knowing, because both fail closed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;relative&lt;/strong&gt; URL like &lt;code&gt;/thank-you&lt;/code&gt; always works.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;absolute&lt;/strong&gt; URL like &lt;code&gt;https://example.com/thank-you&lt;/code&gt; only works if that domain is on your form's allowlist. With an empty allowlist, absolute redirects are rejected outright. This is deliberate: it stops a stolen key from bouncing your visitors to a phishing page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An invalid redirect returns &lt;code&gt;400 invalid_redirect&lt;/code&gt; and nothing is stored, so you'll notice immediately rather than silently losing submissions.&lt;/p&gt;

&lt;p&gt;Use redirect for maximum simplicity and resilience; use JSON for a smoother single-page feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping spam without CAPTCHA
&lt;/h2&gt;

&lt;p&gt;CAPTCHA puzzles annoy real users and hurt conversions. You can stop most bot spam with three quiet techniques, no puzzles required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honeypot field:&lt;/strong&gt; a hidden input real users never fill in, but bots do. Hide it from humans and screen readers, then reject any submission where it's filled. The field name has to match whatever your provider looks for. Formpaste uses &lt;code&gt;botcheck&lt;/code&gt;:&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="c"&gt;&amp;lt;!-- Bots fill this; humans never see it --&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;"botcheck"&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="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="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you invent your own field name, the server has no idea it's a honeypot and will just store it as an ordinary form field. Check your provider's docs for the exact name; this is a quiet failure that only shows up later as spam in your inbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timing check:&lt;/strong&gt; legitimate users take a few seconds to fill a form; bots submit in milliseconds. Record when the form loads and flag submissions that arrive suspiciously fast. Formpaste reads this from a &lt;code&gt;_ts&lt;/code&gt; field holding the page-load timestamp:&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;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"_ts"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form-ts"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form-ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rate limiting:&lt;/strong&gt; cap submissions per IP per minute so a bot can't hammer your endpoint. This one genuinely requires the server side.&lt;/p&gt;

&lt;p&gt;Implementing all three yourself means running server logic, which defeats the "no backend" goal. This is where a form-backend API earns its keep: good ones run honeypot, timing, rate-limit, disposable-email, and duplicate checks for you, then hold anything suspicious in a quarantine you can review rather than silently dropping real messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common errors (and how to fix them)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"My form submits but I get no email."&lt;/strong&gt; Check your spam folder, confirm you verified your sender email, and make sure the &lt;code&gt;access_key&lt;/code&gt; value is correct and not still the placeholder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"I get a 403 &lt;code&gt;domain_not_allowed&lt;/code&gt;."&lt;/strong&gt; Your form has a domain allowlist and the request came from a domain that isn't on it. Add the domain in the dashboard. Note this is a normal JSON error response, not a CORS error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"CORS error in the console."&lt;/strong&gt; This is a different problem from the one above, and it's worth separating them. A form endpoint that's meant for static sites should send permissive CORS headers, so a genuine CORS failure usually means the request never reached the API at all: a typo'd endpoint URL, a network/DNS failure, or an adblocker. Check the Network tab to see whether a response came back at all. Also worth knowing: CORS is a browser policy, not a security boundary. It doesn't protect your key, and the domain allowlist is what actually restricts who can submit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Nothing happens on submit."&lt;/strong&gt; If you're using the JS version, confirm your &lt;code&gt;getElementById&lt;/code&gt;/selector actually matches the form, and that you called &lt;code&gt;e.preventDefault()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"It works locally but not in production."&lt;/strong&gt; Almost always the domain allowlist. Add your production domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"&lt;code&gt;e.currentTarget&lt;/code&gt; is null."&lt;/strong&gt; Classic async footgun: &lt;code&gt;currentTarget&lt;/code&gt; is cleared once the handler returns, so reading it after an &lt;code&gt;await&lt;/code&gt; blows up. Capture it into a variable first, as the examples above do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a service
&lt;/h2&gt;

&lt;p&gt;A quick, honest lay of the land:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Formspree&lt;/strong&gt; is mature and popular, with a generous ecosystem. A safe default, though the free tier is limited. &lt;strong&gt;Web3Forms&lt;/strong&gt; is genuinely no-signup and access-key based, great for quick throwaway forms. &lt;strong&gt;Formpaste&lt;/strong&gt; (what I used here) leans into paste-a-component simplicity, CAPTCHA-free spam filtering with a quarantine you control, and, unusually, an MCP server so an AI coding agent can wire the form up for you. &lt;strong&gt;Netlify Forms&lt;/strong&gt; is excellent but only if you're already hosting on Netlify.&lt;/p&gt;

&lt;p&gt;Any of them work with every code sample above. Swap the endpoint and key, and check each provider's docs for its own field names, since honeypot and redirect conventions differ between services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Adding a contact form to a static site no longer means standing up a server. You've now got a complete, accessible, progressively-enhanced form, working versions for five frameworks, a CAPTCHA-free spam strategy, and a fix-list for the usual gotchas.&lt;/p&gt;

&lt;p&gt;Start with the plain HTML form, enhance with JavaScript when you want inline feedback, and let a form-backend API handle delivery and spam so you can get back to building.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do you use for forms on static sites? Drop your approach in the comments 👇&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>contactform</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Install Postiz on a Local Windows Machine with Only Docker Desktop</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Tue, 21 Jul 2026 08:04:12 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/how-to-install-postiz-on-a-local-windows-machine-1544</link>
      <guid>https://dev.to/mathiasahlgren/how-to-install-postiz-on-a-local-windows-machine-1544</guid>
      <description>&lt;p&gt;&lt;strong&gt;&lt;a href="https://postiz.com" rel="noopener noreferrer"&gt;Postiz&lt;/a&gt; is the hugely popular open-source social media scheduler, a self-hosted alternative to Buffer or Hootsuite with over 33,000 GitHub stars. This guide walks through installing it on Windows with Docker Desktop.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are like me, and found that the official Postiz documentation is too vague, then this step by step is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Read this first: the local-hosting caveat
&lt;/h2&gt;

&lt;p&gt;One thing to know before you start:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postiz only publishes posts while your machine is running.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scheduling is handled by a background worker inside the Docker stack. If you schedule a post for 9:00 AM Tuesday and your PC is asleep, shut down, or Docker isn't running, the post does not go out. It fires late, when the stack next starts.&lt;/p&gt;

&lt;p&gt;Windows sleep settings are the usual culprit. A machine that suspends&lt;br&gt;
overnight will miss anything scheduled overnight, so check your power plan if posts land hours late.&lt;/p&gt;

&lt;p&gt;That makes a local install great for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trying Postiz before committing to a server&lt;/li&gt;
&lt;li&gt;Drafting and organising content&lt;/li&gt;
&lt;li&gt;Development and testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...and a poor fit for reliably hitting a posting schedule. If you need posts to land on time, put it on a VPS or use the hosted version. Everything below applies to a server too - only the "keep the machine on" problem goes away.&lt;/p&gt;

&lt;p&gt;If scheduling is a dealbreaker then you can either pay for Postiz, or host it on a VPS. A cheap VPS is enough; I've written up &lt;a href="https://stackrater.io/hosting/" rel="noopener noreferrer"&gt;which hosts are actually worth it&lt;/a&gt; here.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Windows 10 or 11&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Desktop&lt;/strong&gt; - &lt;a href="https://docker.com/products/docker-desktop" rel="noopener noreferrer"&gt;https://docker.com/products/docker-desktop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git&lt;/strong&gt; (for Git Bash) - &lt;a href="https://git-scm.com/download/win" rel="noopener noreferrer"&gt;https://git-scm.com/download/win&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~15 GB free disk space.&lt;/strong&gt; The stack pulls about 9.5 GB of images, plus
room for volumes and overhead. (Measured on one install - image sizes drift, so treat it as a ballpark.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 GB RAM minimum&lt;/strong&gt;; the stack idles around 3–4 GB. Note that Docker Desktop's WSL2 backend has its own memory ceiling on top of that, so 8 GB total leaves very little headroom. 16 GB is a lot more comfortable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Times below assume a reasonably fast connection. The image pull dominates.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 - Install and start Docker Desktop
&lt;/h2&gt;

&lt;p&gt;Install it, then &lt;strong&gt;launch it and wait for the whale icon in the system tray to stop animating.&lt;/strong&gt; The Docker engine runs in a Linux VM that takes 30–60 seconds to boot. Every &lt;code&gt;docker&lt;/code&gt; command fails until it's up.&lt;/p&gt;

&lt;p&gt;Turn on &lt;strong&gt;Settings → General → Start Docker Desktop when you log in.&lt;/strong&gt;&lt;br&gt;
Postiz can't publish anything if Docker isn't running, so on a local install this is doing real work for you.&lt;/p&gt;

&lt;p&gt;Verify in Git Bash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any version number means the CLI is reachable and the engine is up. If you get&lt;br&gt;
&lt;code&gt;bash: docker: command not found&lt;/code&gt;, see Pitfall 1.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2 - Get the compose file
&lt;/h2&gt;

&lt;p&gt;Pick wherever you keep projects. In Git Bash, your Windows user folder is&lt;br&gt;
&lt;code&gt;~&lt;/code&gt;, so this puts it in &lt;code&gt;C:\Users\&amp;lt;you&amp;gt;\projects\postiz&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/projects &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects
git clone https://github.com/gitroomhq/postiz-docker-compose postiz
&lt;span class="nb"&gt;cd &lt;/span&gt;postiz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is just the Docker Compose configuration - the application itself&lt;br&gt;
arrives as prebuilt images.&lt;/p&gt;

&lt;p&gt;The folder name matters a little: Compose prefixes its volumes and networks with it. Clone into &lt;code&gt;postiz&lt;/code&gt; and you get &lt;code&gt;postiz_postgres-volume&lt;/code&gt;; clone into &lt;code&gt;my-postiz&lt;/code&gt; and it's &lt;code&gt;my-postiz_postgres-volume&lt;/code&gt;. Commands below assume &lt;code&gt;postiz&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3 - Set a real JWT secret
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker-compose.yaml&lt;/code&gt; ships with a placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;random&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;that&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unique&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;every&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;install&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;just&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;random&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;characters&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;here!'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string signs your login tokens, and it's identical in every copy of the repo. Generate your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-base64&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the result in place of the placeholder. Do this &lt;strong&gt;before&lt;/strong&gt; first launch - changing it later invalidates existing sessions.&lt;/p&gt;

&lt;p&gt;Two other settings worth knowing, both near the top of the file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DISABLE_REGISTRATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'false'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signup is open. Needed for your first account - close it afterwards (Step 6).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MAIN_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:4007&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Change only if you're not on localhost.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 4 - Start it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run pulls roughly 9.5 GB across 8 images - expect 5–15 minutes.&lt;br&gt;
Subsequent starts take well under a minute.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-d&lt;/code&gt; runs it detached, in the background.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5 - Wait for it to actually be ready
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You want all services showing &lt;code&gt;(healthy)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postiz                   Up (healthy)
postiz-postgres          Up (healthy)
postiz-redis             Up (healthy)
temporal                 Up (healthy)
temporal-postgresql      Up (healthy)
temporal-elasticsearch   Up (healthy)
temporal-ui              Up (healthy)
temporal-admin-tools     Up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Services start in dependency order - Elasticsearch, then Temporal, then&lt;br&gt;
Postiz - and Postiz runs database migrations on first boot. Seeing&lt;br&gt;
&lt;code&gt;(health: starting)&lt;/code&gt; for two or three minutes is normal.&lt;/p&gt;

&lt;p&gt;Then open:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="http://localhost:4007" rel="noopener noreferrer"&gt;http://localhost:4007&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;http://&lt;/code&gt;, not &lt;code&gt;https://&lt;/code&gt;. Nothing here terminates TLS, so the &lt;code&gt;https&lt;/code&gt;&lt;br&gt;
URL simply fails.&lt;/p&gt;

&lt;p&gt;Register - the first account is the admin.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 6 - Close registration
&lt;/h2&gt;

&lt;p&gt;Once your account exists, stop anyone else from creating one. In&lt;br&gt;
&lt;code&gt;docker-compose.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;DISABLE_REGISTRATION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That recreates only the changed container. Your account and data live in&lt;br&gt;
named volumes and are untouched - you stay logged in.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 7 - Keep secrets out of version control
&lt;/h2&gt;

&lt;p&gt;Optional, but worth doing if you'll ever commit this config or copy it to a server.&lt;/p&gt;

&lt;p&gt;Docker Compose automatically reads a &lt;code&gt;.env&lt;/code&gt; file sitting next to&lt;br&gt;
&lt;code&gt;docker-compose.yaml&lt;/code&gt;. Move the secrets there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-generated-secret&lt;/span&gt;
&lt;span class="py"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postiz-user&lt;/span&gt;
&lt;span class="py"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postiz-password&lt;/span&gt;
&lt;span class="py"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;postiz-db-local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker-compose.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_SECRET:?set JWT_SECRET in .env}&lt;/span&gt;
&lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postiz-postgres:5432/${POSTGRES_DB}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:?&lt;/code&gt; syntax makes Compose fail with a clear message if the variable is&lt;br&gt;
missing, rather than silently starting with an empty secret.&lt;/p&gt;

&lt;p&gt;Check the substitution before restarting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose config | &lt;span class="nb"&gt;grep &lt;/span&gt;JWT_SECRET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;.env&lt;/code&gt; is set up correctly, this prints the resolved value. If the variable is missing, &lt;code&gt;docker compose config&lt;/code&gt; &lt;strong&gt;exits with an error&lt;/strong&gt; rather than printing anything - the &lt;code&gt;grep&lt;/code&gt; never runs. Either outcome tells you what you&lt;br&gt;
need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error while interpolating services.postiz.environment.JWT_SECRET:
required variable JWT_SECRET is missing a value: set JWT_SECRET in .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Back &lt;code&gt;.env&lt;/code&gt; up somewhere outside the repo.&lt;/strong&gt; It's git-ignored by design,&lt;br&gt;
which also means it isn't backed up. Lose the &lt;code&gt;JWT_SECRET&lt;/code&gt; and every session breaks.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 8 - Connect social channels
&lt;/h2&gt;

&lt;p&gt;Postiz reads platform credentials from the &lt;strong&gt;container's environment&lt;/strong&gt;, not from anything in the web UI. There's no dashboard field for these - you set them in config and restart.&lt;/p&gt;

&lt;p&gt;Using the &lt;code&gt;.env&lt;/code&gt; pattern from Step 7, take X as the example:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;X_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-key-here&lt;/span&gt;
&lt;span class="py"&gt;X_API_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your-secret-here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker-compose.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;X_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${X_API_KEY:-}&lt;/span&gt;
&lt;span class="na"&gt;X_API_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${X_API_SECRET:-}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;:-&lt;/code&gt; default keeps blank values valid, so platforms you haven't set up&lt;br&gt;
stay unavailable instead of breaking startup.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;docker compose up -d&lt;/code&gt; and connect the channel in the UI.&lt;/p&gt;
&lt;h3&gt;
  
  
  The full list of supported platforms
&lt;/h3&gt;

&lt;p&gt;These are the variable names the compose file ships with. Wire up only the&lt;br&gt;
ones you need - the pattern is identical for each: add the variables to&lt;br&gt;
&lt;code&gt;.env&lt;/code&gt;, switch the compose file to &lt;code&gt;${VAR:-}&lt;/code&gt;, restart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Variables&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;X (Twitter)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X_API_KEY&lt;/code&gt;, &lt;code&gt;X_API_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;FACEBOOK_APP_ID&lt;/code&gt;, &lt;code&gt;FACEBOOK_APP_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram / Threads&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;THREADS_APP_ID&lt;/code&gt;, &lt;code&gt;THREADS_APP_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LINKEDIN_CLIENT_ID&lt;/code&gt;, &lt;code&gt;LINKEDIN_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TikTok&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TIKTOK_CLIENT_ID&lt;/code&gt;, &lt;code&gt;TIKTOK_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;YOUTUBE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;YOUTUBE_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinterest&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PINTEREST_CLIENT_ID&lt;/code&gt;, &lt;code&gt;PINTEREST_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reddit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;REDDIT_CLIENT_ID&lt;/code&gt;, &lt;code&gt;REDDIT_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;MASTODON_CLIENT_ID&lt;/code&gt;, &lt;code&gt;MASTODON_CLIENT_SECRET&lt;/code&gt;, &lt;code&gt;MASTODON_URL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DISCORD_CLIENT_ID&lt;/code&gt;, &lt;code&gt;DISCORD_CLIENT_SECRET&lt;/code&gt;, &lt;code&gt;DISCORD_BOT_TOKEN_ID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SLACK_ID&lt;/code&gt;, &lt;code&gt;SLACK_SECRET&lt;/code&gt;, &lt;code&gt;SLACK_SIGNING_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dribbble&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DRIBBBLE_CLIENT_ID&lt;/code&gt;, &lt;code&gt;DRIBBBLE_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GITHUB_CLIENT_ID&lt;/code&gt;, &lt;code&gt;GITHUB_CLIENT_SECRET&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beehiiv&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BEEHIIVE_API_KEY&lt;/code&gt;, &lt;code&gt;BEEHIIVE_PUBLICATION_ID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;MASTODON_URL&lt;/code&gt; defaults to &lt;code&gt;https://mastodon.social&lt;/code&gt; - change it if you're on a different instance. Note &lt;code&gt;BEEHIIVE_*&lt;/code&gt; is spelled with the extra "E" in the compose file; copy it exactly.&lt;/p&gt;

&lt;p&gt;Postiz supports more platforms than this in its UI. If one you want isn't&lt;br&gt;
listed, check the&lt;br&gt;
&lt;a href="https://docs.postiz.com/configuration/reference" rel="noopener noreferrer"&gt;configuration reference&lt;/a&gt; for its variable names and add them the same way.&lt;/p&gt;

&lt;p&gt;Each platform needs an app registered on its developer portal, and each needs a callback URL whitelisted - typically &lt;code&gt;http://localhost:4007/integrations/social/&amp;lt;platform&amp;gt;&lt;/code&gt;. Check&lt;br&gt;
&lt;code&gt;docker compose logs postiz&lt;/code&gt; for the exact URL Postiz expects if a connection fails.&lt;/p&gt;
&lt;h3&gt;
  
  
  Start with an easy one
&lt;/h3&gt;

&lt;p&gt;Platforms differ enormously in how much work they take, and it's worth knowing before you sink an afternoon into the wrong one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Easy&lt;/strong&gt; - Mastodon, Discord, Reddit, GitHub. Credentials issued
immediately, no review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moderate&lt;/strong&gt; - LinkedIn (needs a company page), Pinterest, Slack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard&lt;/strong&gt; - Facebook, Instagram/Threads, TikTok, YouTube. App review,
sandbox restrictions, or business verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid&lt;/strong&gt; - X requires a paid API tier for write access. The free tier is
read-only, so posting won't work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you just want to prove the pipeline works end to end, &lt;strong&gt;Mastodon&lt;/strong&gt; takes about two minutes: Preferences → Development → New application in any Mastodon instance.&lt;/p&gt;

&lt;p&gt;These requirements change frequently - treat the grouping above as a starting point and check the current terms on each portal.&lt;/p&gt;
&lt;h2&gt;
  
  
  Everyday commands
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps             &lt;span class="c"&gt;# status&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; postiz &lt;span class="c"&gt;# follow logs, Ctrl+C to quit&lt;/span&gt;
docker compose stop           &lt;span class="c"&gt;# stop, keeps all data&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;          &lt;span class="c"&gt;# start&lt;/span&gt;
docker compose restart postiz &lt;span class="c"&gt;# restart just the app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Data lives in Docker named volumes and survives &lt;code&gt;stop&lt;/code&gt;, &lt;code&gt;up -d&lt;/code&gt;, &lt;code&gt;restart&lt;/code&gt;,&lt;br&gt;
and &lt;code&gt;down&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker compose down -v&lt;/code&gt; deletes the volumes - that erases your account,&lt;br&gt;
posts, and uploads.&lt;/strong&gt; It's the one command to be careful with.&lt;/p&gt;


&lt;h2&gt;
  
  
  Troubleshooting and common pitfalls
&lt;/h2&gt;
&lt;h2&gt;
  
  
  Pitfall 1: &lt;code&gt;bash: docker: command not found&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Docker Desktop is installed, but your shell can't find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, check Docker Desktop is actually running.&lt;/strong&gt; No amount of PATH fixing helps if the engine is down.&lt;/p&gt;

&lt;p&gt;If it is running, check whether the CLI is on your PATH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means the directory is missing. Find the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"/c/Program Files/Docker/Docker/resources/bin/docker.exe"&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCALAPPDATA&lt;/span&gt;&lt;span class="s2"&gt;/Programs/DockerDesktop/resources/bin/docker.exe"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent Docker Desktop versions may install &lt;strong&gt;per-user&lt;/strong&gt; to&lt;br&gt;
&lt;code&gt;%LOCALAPPDATA%\Programs\DockerDesktop&lt;/code&gt; rather than &lt;code&gt;C:\Program Files\Docker&lt;/code&gt;. Guides that assume Program Files won't match.&lt;/p&gt;

&lt;p&gt;Add whichever path exists to &lt;code&gt;~/.bashrc&lt;/code&gt;. &lt;code&gt;$HOME&lt;/code&gt; keeps your username out of it and stays in Unix-style path format, which is what &lt;code&gt;PATH&lt;/code&gt; wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PATH="$PATH:$HOME/AppData/Local/Programs/DockerDesktop/resources/bin"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If yours is the Program Files install, use&lt;br&gt;
&lt;code&gt;/c/Program Files/Docker/Docker/resources/bin&lt;/code&gt; instead.)&lt;/p&gt;

&lt;p&gt;Avoid &lt;code&gt;$LOCALAPPDATA&lt;/code&gt; here. It expands to a Windows-style path with&lt;br&gt;
backslashes and a drive letter (&lt;code&gt;C:\Users\you\AppData\Local&lt;/code&gt;), and a &lt;code&gt;C:&lt;/code&gt; in a colon-separated &lt;code&gt;PATH&lt;/code&gt; is asking for trouble. &lt;code&gt;$HOME&lt;/code&gt; is already&lt;br&gt;
&lt;code&gt;/c/Users/you&lt;/code&gt; in Git Bash.&lt;/p&gt;

&lt;p&gt;A subtlety worth knowing: even when the directory &lt;em&gt;is&lt;/em&gt; correctly registered in your Windows PATH, terminals inherit their environment from &lt;code&gt;explorer.exe&lt;/code&gt;, which may hold a stale copy from before the install. Opening a new terminal doesn't always help - a reboot does. Editing &lt;code&gt;~/.bashrc&lt;/code&gt; sidesteps the whole problem.&lt;/p&gt;

&lt;p&gt;Note that your project's location on disk is irrelevant here. PATH is a list of absolute directories; where you run the command from doesn't matter.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall 2: &lt;code&gt;502 Bad Gateway&lt;/code&gt; when logging in or registering
&lt;/h2&gt;

&lt;p&gt;The page loads, but submitting the form returns nginx's 502.&lt;/p&gt;

&lt;p&gt;This means the &lt;strong&gt;frontend is fine and the backend is dead.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;postiz&lt;/code&gt; container isn't one process - it runs several behind a single&lt;br&gt;
nginx instance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Process&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nginx&lt;/td&gt;
&lt;td&gt;5000&lt;/td&gt;
&lt;td&gt;front door; routes everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;frontend (Next.js)&lt;/td&gt;
&lt;td&gt;4200&lt;/td&gt;
&lt;td&gt;the UI you see&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;backend (NestJS)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the API - &lt;code&gt;/api/*&lt;/code&gt; proxies here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;workers / cron&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;publishing and scheduled jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;nginx serves the UI happily whether or not the backend is alive. So the page renders, then every API call - login, registration - returns 502.&lt;/p&gt;

&lt;p&gt;Check whether the backend is listening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;postiz ss &lt;span class="nt"&gt;-tln&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;ss&lt;/code&gt; isn't in the image, use Node, which is always present:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;postiz node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"require('net').connect(3000,'127.0.0.1').on('connect',()=&amp;gt;{console.log('backend UP');process.exit(0)}).on('error',()=&amp;gt;{console.log('backend DOWN');process.exit(1)})"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;LISTEN&lt;/code&gt; line (or &lt;code&gt;backend UP&lt;/code&gt;) → backend is fine; look elsewhere.&lt;/li&gt;
&lt;li&gt;No output (or &lt;code&gt;backend DOWN&lt;/code&gt;) → backend is down. Find out why:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs postiz | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"backend failed"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfall 3: "All services healthy" but the app is broken
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This is the trap that makes Pitfall 2 confusing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose ps&lt;/code&gt; can report every service &lt;code&gt;(healthy)&lt;/code&gt; while Postiz is&lt;br&gt;
fundamentally broken. Look at what the compose file's health check actually tests - it fetches &lt;code&gt;http://localhost:5000/&lt;/code&gt;, which is nginx. And nginx stays perfectly healthy serving the frontend even when the backend behind it has crashed.&lt;/p&gt;

&lt;p&gt;So on this stack, &lt;code&gt;(healthy)&lt;/code&gt; means "the web server is up," not "the&lt;br&gt;
application works." When something misbehaves, the port 3000 check above is a far better signal than the status column.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall 4: Don't remove services to save RAM
&lt;/h2&gt;

&lt;p&gt;The stack runs 8 containers and the Temporal portion looks like overkill for a personal install. Resist trimming it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;temporal-elasticsearch&lt;/code&gt; in particular looks optional and is not.&lt;/strong&gt;&lt;br&gt;
Elasticsearch is genuinely optional for Temporal in general - it powers&lt;br&gt;
"advanced visibility," and Temporal falls back to PostgreSQL without it. But Postiz registers custom Temporal &lt;em&gt;search attributes&lt;/em&gt; at startup, and the PostgreSQL visibility store caps Text-type attributes at three. Postiz needs more.&lt;/p&gt;

&lt;p&gt;Remove Elasticsearch and the backend dies during initialisation with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to create search attributes: cannot have more than 3 search attribute of type Text.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...which surfaces to you as a 502 on login, with every service reporting&lt;br&gt;
healthy. The compose file ships these services for a reason.&lt;/p&gt;

&lt;p&gt;If you already removed it, restore the original Elasticsearch service, then wipe Temporal's state so it re-initialises consistently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
docker volume &lt;span class="nb"&gt;rm &lt;/span&gt;postiz_temporal-postgres-data
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removes Temporal's database only. Your Postiz account and posts live in &lt;code&gt;postiz_postgres-volume&lt;/code&gt; and are unaffected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your volume names first&lt;/strong&gt; - Compose prefixes them with the &lt;em&gt;directory name&lt;/em&gt;, so they're &lt;code&gt;postiz_*&lt;/code&gt; only if you cloned into a folder called &lt;code&gt;postiz&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;temporal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfall 5: &lt;code&gt;/api/copilot/chat&lt;/code&gt; timeouts in the logs
&lt;/h2&gt;

&lt;p&gt;Harmless. That's the AI assistant feature failing because &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; is empty. Ignore it unless you want AI-generated post content, in which case supply a key.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should you self-host locally at all?
&lt;/h2&gt;

&lt;p&gt;Postiz is the best self-hosted Buffer alternative out there. Local hosting it is a genuinely good way to evaluate Postiz, learn the stack, and draft content without paying for anything. BUT, it is not a good way to hit a posting schedule.&lt;/p&gt;

&lt;p&gt;If scheduling reliability matters, the same compose file runs on a small VPS - you'd change &lt;code&gt;MAIN_URL&lt;/code&gt;, &lt;code&gt;FRONTEND_URL&lt;/code&gt;, and &lt;code&gt;NEXT_PUBLIC_BACKEND_URL&lt;/code&gt; to your domain and put a reverse proxy with TLS in front. Everything else transfers unchanged.&lt;/p&gt;

&lt;p&gt;A cheap VPS is enough; I've written up &lt;a href="https://stackrater.io/" rel="noopener noreferrer"&gt;which hosts are actually worth signing up for&lt;/a&gt; separately, along with the renewal-pricing traps to avoid.&lt;/p&gt;

</description>
      <category>postiz</category>
      <category>docker</category>
      <category>socialmedia</category>
      <category>selfhost</category>
    </item>
    <item>
      <title>Australian Exchange Rates: A Free Currency Converter API for Developers</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Tue, 14 Jul 2026 07:44:38 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/australian-exchange-rates-a-free-currency-converter-api-for-developers-58i6</link>
      <guid>https://dev.to/mathiasahlgren/australian-exchange-rates-a-free-currency-converter-api-for-developers-58i6</guid>
      <description>&lt;p&gt;&lt;strong&gt;If you build software for Australian businesses, exchange rates are rarely “just another data point.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They affect invoice calculations, cross-border e-commerce, tax reporting, accounting workflows, financial reconciliation, and audit trails. For many use cases, you need a reliable source of official Australian exchange rate data, not a scraped webpage or an undocumented feed that could change without warning.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.exchangeratesapi.com.au" rel="noopener noreferrer"&gt;Exchange Rates API&lt;/a&gt; provides developer-friendly access to official Reserve Bank of Australia (RBA) exchange rates through simple REST endpoints, historical data, currency conversion, and webhook notifications.&lt;/p&gt;

&lt;p&gt;This article explains how to retrieve Australian exchange rates, convert currencies, and avoid inefficient REST polling by using webhooks to be notified when the day’s rates are ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why official RBA exchange-rate data matters
&lt;/h2&gt;

&lt;p&gt;The RBA publishes daily exchange-rate data that is useful for Australian businesses dealing with international currencies. However, accessing that source data directly can be inconvenient because it is published as RSS/XML feeds, and Excel files.&lt;/p&gt;

&lt;p&gt;That creates avoidable engineering work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scraping HTML is fragile.&lt;/li&gt;
&lt;li&gt;Parsing XML and RDF namespaces is cumbersome.&lt;/li&gt;
&lt;li&gt;Spreadsheet-based workflows are difficult to automate.&lt;/li&gt;
&lt;li&gt;Handling historical data consistently takes time.&lt;/li&gt;
&lt;li&gt;Monitoring for the day’s newly published rates adds more complexity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exchange Rates API turns that data into clean JSON endpoints built for applications.&lt;/p&gt;

&lt;p&gt;The service provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Official RBA-sourced currency exchange data&lt;/li&gt;
&lt;li&gt;AUD as the base currency&lt;/li&gt;
&lt;li&gt;Daily updated rates&lt;/li&gt;
&lt;li&gt;Historical data from 2018 onwards&lt;/li&gt;
&lt;li&gt;More than 30,000 historical data points&lt;/li&gt;
&lt;li&gt;A free starting tier with 300 one-time trial requests and no credit card required&lt;/li&gt;
&lt;li&gt;REST API endpoints plus webhooks for eligible plans&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Exchange Rates API sources data from the Reserve Bank of Australia, but it is not affiliated with or endorsed by the RBA.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Get your first Australian exchange rate
&lt;/h2&gt;

&lt;p&gt;After getting an API key from the &lt;a href="https://app.exchangeratesapi.com.au/login" rel="noopener noreferrer"&gt;Exchange Rates API dashboard&lt;/a&gt;, request the latest rates with the &lt;code&gt;/latest&lt;/code&gt; endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.exchangeratesapi.com.au/latest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer your_api_key_here"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1725080400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AUD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-08-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.643512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.562934&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"GBP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.487421&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"JPY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;96.8321&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"NZD"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0942&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"TWI"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;60.5&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the response contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.643512&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Then 1 AUD equals 0.643512 USD.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the API in JavaScript
&lt;/h2&gt;

&lt;p&gt;Here is a minimal JavaScript example using &lt;code&gt;fetch&lt;/code&gt;.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.exchangeratesapi.com.au/latest&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;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;EXCHANGE_RATES_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="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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Exchange Rates API error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="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;response&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Rate date: &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="nx"&gt;date&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`1 AUD = &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="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;USD&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; USD`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`1 AUD = &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="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EUR&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; EUR`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, keep your API key in an environment variable or secret manager. Do not put it in browser-side code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convert AUD to another currency
&lt;/h2&gt;

&lt;p&gt;A simple conversion is multiplication when the rate is quoted relative to AUD.&lt;/p&gt;

&lt;p&gt;For example, to convert an Australian invoice total into USD:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;250&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;usdRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;USD&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;usdAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;usdRate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`A$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;audAmount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = US$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;usdAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If 1 AUD equals 0.643512 USD, then A$250 equals approximately US$160.88.&lt;/p&gt;

&lt;p&gt;Depending on your product and reporting obligations, decide explicitly how and when to round monetary values. It is also good practice to retain the original rate, source date, converted amount, and rounding rule alongside every transaction.&lt;/p&gt;

&lt;p&gt;That makes later reconciliation and audit work much easier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supported currencies
&lt;/h2&gt;

&lt;p&gt;Exchange Rates API supports the currencies published by the RBA, including major currencies and key Asia-Pacific trading currencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;USD, US dollar&lt;/li&gt;
&lt;li&gt;EUR, euro&lt;/li&gt;
&lt;li&gt;GBP, British pound sterling&lt;/li&gt;
&lt;li&gt;JPY, Japanese yen&lt;/li&gt;
&lt;li&gt;CHF, Swiss franc&lt;/li&gt;
&lt;li&gt;CAD, Canadian dollar&lt;/li&gt;
&lt;li&gt;CNY, Chinese renminbi&lt;/li&gt;
&lt;li&gt;KRW, South Korean won&lt;/li&gt;
&lt;li&gt;SGD, Singapore dollar&lt;/li&gt;
&lt;li&gt;NZD, New Zealand dollar&lt;/li&gt;
&lt;li&gt;HKD, Hong Kong dollar&lt;/li&gt;
&lt;li&gt;INR, Indian rupee&lt;/li&gt;
&lt;li&gt;THB, Thai baht&lt;/li&gt;
&lt;li&gt;MYR, Malaysian ringgit&lt;/li&gt;
&lt;li&gt;IDR, Indonesian rupiah&lt;/li&gt;
&lt;li&gt;VND, Vietnamese dong&lt;/li&gt;
&lt;li&gt;PHP, Philippine peso&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can retrieve the latest exchange rates, request historical rates, look up rates for a particular currency, or use the currency conversion endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with polling for daily rates
&lt;/h2&gt;

&lt;p&gt;A common integration pattern is polling: repeatedly requesting &lt;code&gt;/latest&lt;/code&gt; until the day’s rate arrives.&lt;/p&gt;

&lt;p&gt;For example:&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="nf"&gt;setInterval&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="o"&gt;=&amp;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;response&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.exchangeratesapi.com.au/latest&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;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;EXCHANGE_RATES_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="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;response&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="k"&gt;if &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="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Today's rates are available.&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;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, but it is not ideal.&lt;/p&gt;

&lt;p&gt;The RBA targets publication around 4:00 PM Australian Eastern Time on business days. In practice, publication can drift, often landing between 4:00 PM and 5:00 PM, and sometimes later. Rates are not published on weekends or NSW public holidays.&lt;/p&gt;

&lt;p&gt;That uncertainty encourages frequent polling. If you poll every 15 minutes over a typical business day, your application can make dozens of requests simply to discover whether a new daily data set exists.&lt;/p&gt;

&lt;p&gt;Polling creates several issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wasted API requests:&lt;/strong&gt; Most calls return data you already have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher quota usage:&lt;/strong&gt; Repeated calls consume your request allowance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delayed processing:&lt;/strong&gt; Your application only notices a new rate at the next scheduled interval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More operational complexity:&lt;/strong&gt; You need schedules, holiday handling, retries, and time-zone logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extra infrastructure work:&lt;/strong&gt; Background workers and cron jobs run even when no new rates have been published.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a workflow based on a single daily event, there is a better approach: webhooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use webhooks to get notified instead
&lt;/h2&gt;

&lt;p&gt;A webhook reverses the flow of control.&lt;/p&gt;

&lt;p&gt;Instead of your application repeatedly asking, “Are today’s rates ready yet?”, Exchange Rates API notifies your application when it detects that the RBA has published new rates.&lt;/p&gt;

&lt;p&gt;You register an HTTPS endpoint, and the API sends a signed HTTP &lt;code&gt;POST&lt;/code&gt; request to that URL. This normally happens within ten minutes of publication.&lt;/p&gt;

&lt;p&gt;With polling, the flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your app asks the API whether rates are ready.&lt;/li&gt;
&lt;li&gt;The API returns the currently available data.&lt;/li&gt;
&lt;li&gt;Your app waits for a scheduled interval.&lt;/li&gt;
&lt;li&gt;Your app asks again.&lt;/li&gt;
&lt;li&gt;This repeats until the latest daily rates are available.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With a webhook, the flow is simpler:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The RBA publishes new daily rates.&lt;/li&gt;
&lt;li&gt;Exchange Rates API detects the new data.&lt;/li&gt;
&lt;li&gt;Exchange Rates API sends your application a signed HTTP &lt;code&gt;POST&lt;/code&gt; request.&lt;/li&gt;
&lt;li&gt;Your application receives and processes the rates immediately.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Webhooks are available on Professional plans and above. REST endpoints remain available, so webhooks are an addition to REST, not a replacement.&lt;/p&gt;

&lt;p&gt;A strong implementation uses both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks&lt;/strong&gt; for prompt, event-driven rate updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REST endpoints&lt;/strong&gt; for historical queries, backfills, reconciliation, manual refreshes, and recovery workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What a rate-published webhook looks like
&lt;/h2&gt;

&lt;p&gt;When new rates are available, Exchange Rates API sends JSON to your registered endpoint.&lt;/p&gt;

&lt;p&gt;The request includes headers such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Type: application/json
X-ExchangeRates-Event: rates.published
X-ExchangeRates-Signature: sha256=&amp;lt;hmac&amp;gt;
X-ExchangeRates-Delivery: &amp;lt;unique-delivery-uuid&amp;gt;
User-Agent: ExchangeRatesAPI-Webhook/1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example webhook payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rates.published"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-23"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AUD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.7004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"JPY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;113.25&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0b9d8f60-a5c3-e1d9-b2f4-a6c83f9c4e2a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1782086400&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on your subscription configuration, the &lt;code&gt;rates&lt;/code&gt; object contains every supported currency or only the currencies you selected.&lt;/p&gt;

&lt;p&gt;Once received, your system can immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store the day’s official rates in a database.&lt;/li&gt;
&lt;li&gt;Recalculate customer-facing prices.&lt;/li&gt;
&lt;li&gt;Create accounting conversion records.&lt;/li&gt;
&lt;li&gt;Update internal dashboards.&lt;/li&gt;
&lt;li&gt;Trigger import or export settlement workflows.&lt;/li&gt;
&lt;li&gt;Notify finance teams that rates are ready.&lt;/li&gt;
&lt;li&gt;Start an end-of-day reconciliation process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your endpoint should return any &lt;code&gt;2xx&lt;/code&gt; response to acknowledge that it received the delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify webhook signatures, always
&lt;/h2&gt;

&lt;p&gt;Never trust an incoming webhook simply because it reaches your endpoint.&lt;/p&gt;

&lt;p&gt;Every Exchange Rates API webhook includes an &lt;code&gt;X-ExchangeRates-Signature&lt;/code&gt; header. The signature is an HMAC-SHA256 calculated using the raw request body and your subscription signing secret.&lt;/p&gt;

&lt;p&gt;The important detail is that you must verify the exact raw bytes received.&lt;/p&gt;

&lt;p&gt;Do not parse the JSON and then serialize it again before calculating the signature. Whitespace or key-order changes can produce a different signature, causing verification to fail.&lt;/p&gt;

&lt;p&gt;Here is an Express example that preserves the raw request body:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/exchange-rates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="k"&gt;async &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="o"&gt;=&amp;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;signature&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="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="s2"&gt;X-ExchangeRates-Signature&lt;/span&gt;&lt;span class="dl"&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;signingSecret&lt;/span&gt; &lt;span class="o"&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;EXCHANGE_RATES_WEBHOOK_SECRET&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;expectedSignature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="nx"&gt;crypto&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signingSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&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;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedSignature&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;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&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;401&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="s2"&gt;Invalid webhook signature&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&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;parse&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rates.published&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Received rates for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`1 AUD = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;USD&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; USD`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Save rates, enqueue downstream work, or trigger a business workflow.&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;200&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;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Webhook receiver listening on port 3000&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;Use a constant-time comparison function, such as Node.js &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;, when comparing signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design your webhook receiver for production
&lt;/h2&gt;

&lt;p&gt;A webhook endpoint should be secure, fast, and idempotent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Return a &lt;code&gt;2xx&lt;/code&gt; response quickly
&lt;/h3&gt;

&lt;p&gt;Your receiver should acknowledge a delivery promptly. If processing is slow, such as recalculating prices across a large catalogue, add the work to a queue and process it asynchronously.&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;process-exchange-rates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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;202&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;queued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Deduplicate deliveries
&lt;/h3&gt;

&lt;p&gt;Webhooks can be retried if your endpoint does not return a &lt;code&gt;2xx&lt;/code&gt; response. Store and check the &lt;code&gt;delivery_id&lt;/code&gt; before processing so a duplicate delivery does not create duplicate records or trigger a workflow twice.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyProcessed&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;deliveries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delivery_id&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;alreadyProcessed&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;200&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;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="nx"&gt;deliveries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Store the rate date
&lt;/h3&gt;

&lt;p&gt;The webhook payload includes a &lt;code&gt;date&lt;/code&gt; field. Store that date with the exchange-rate data rather than assuming it is the same as your server’s current date.&lt;/p&gt;

&lt;p&gt;This matters for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Historical reporting&lt;/li&gt;
&lt;li&gt;Financial audits&lt;/li&gt;
&lt;li&gt;Time-zone differences&lt;/li&gt;
&lt;li&gt;Delayed deliveries&lt;/li&gt;
&lt;li&gt;Reconciliation after downtime&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Expect retries
&lt;/h3&gt;

&lt;p&gt;If your endpoint does not return a &lt;code&gt;2xx&lt;/code&gt; response, Exchange Rates API retries the delivery with backoff.&lt;/p&gt;

&lt;p&gt;Deliveries that never succeed are dropped after the retry budget is exhausted. If a subscription fails on seven consecutive publications, it is automatically deactivated.&lt;/p&gt;

&lt;p&gt;Monitor your webhook receiver and alert on failures. If a subscription becomes inactive, fix the endpoint and re-create the subscription once it is healthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep a REST fallback
&lt;/h3&gt;

&lt;p&gt;Webhooks are ideal for immediate notification, but a resilient application should still have a recovery path.&lt;/p&gt;

&lt;p&gt;For example, an overnight reconciliation job can call &lt;code&gt;/latest&lt;/code&gt; or a date-specific endpoint and confirm that the expected daily rate record exists.&lt;/p&gt;

&lt;p&gt;This helps protect your workflow against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Downtime in your own infrastructure&lt;/li&gt;
&lt;li&gt;Missed deliveries&lt;/li&gt;
&lt;li&gt;Incorrect endpoint configuration&lt;/li&gt;
&lt;li&gt;Temporary networking failures&lt;/li&gt;
&lt;li&gt;Operational mistakes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test before waiting for the next rate publication
&lt;/h2&gt;

&lt;p&gt;You should not need to wait until the next business day to validate your webhook endpoint.&lt;/p&gt;

&lt;p&gt;Exchange Rates API provides a test endpoint that sends a signed &lt;code&gt;rates.test&lt;/code&gt; delivery. The payload is signed exactly like a production webhook, which makes it useful for verifying that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your public endpoint is reachable.&lt;/li&gt;
&lt;li&gt;You retain and verify the raw request body.&lt;/li&gt;
&lt;li&gt;Your signing secret is correct.&lt;/li&gt;
&lt;li&gt;Your endpoint returns a &lt;code&gt;2xx&lt;/code&gt; response.&lt;/li&gt;
&lt;li&gt;Your event-processing pipeline works.&lt;/li&gt;
&lt;li&gt;Your logs and alerts are configured correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat test events as non-production data and do not apply them to live financial records.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical architecture for AUD exchange-rate workflows
&lt;/h2&gt;

&lt;p&gt;A typical Australian accounting or e-commerce workflow might look like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register a webhook endpoint.&lt;/li&gt;
&lt;li&gt;Verify the HMAC signature using the raw request body.&lt;/li&gt;
&lt;li&gt;Deduplicate deliveries using &lt;code&gt;delivery_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Persist the official AUD base rates and supplied &lt;code&gt;date&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Queue downstream calculations, notifications, or reporting tasks.&lt;/li&gt;
&lt;li&gt;Return a fast &lt;code&gt;2xx&lt;/code&gt; response.&lt;/li&gt;
&lt;li&gt;Run a periodic REST reconciliation job as a safety net.&lt;/li&gt;
&lt;li&gt;Use historical REST endpoints for reports, corrections, and backfills.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This approach gives you timely daily updates without repeatedly calling an endpoint all afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start building
&lt;/h2&gt;

&lt;p&gt;If your application needs official Australian exchange-rate data, Exchange Rates API provides a practical route from RBA-published rates to usable application data.&lt;/p&gt;

&lt;p&gt;Start with the REST API for current rates, historical data, and currency conversion. When your workflow depends on knowing precisely when daily rates are available, use webhooks and let the update come to you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://app.exchangeratesapi.com.au/login" rel="noopener noreferrer"&gt;Get an API key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.exchangeratesapi.com.au/introduction" rel="noopener noreferrer"&gt;Read the Exchange Rates API introduction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.exchangeratesapi.com.au/api-reference/webhooks/overview#webhooks-overview" rel="noopener noreferrer"&gt;Review the webhook documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rba</category>
      <category>australia</category>
      <category>api</category>
    </item>
    <item>
      <title>One API endpoint, 18 registries: verifying Australian businesses without the SOAP and CSV mess</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Tue, 07 Jul 2026 01:59:26 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/one-api-endpoint-18-registries-verifying-australian-businesses-without-the-soap-and-csv-mess-1nmp</link>
      <guid>https://dev.to/mathiasahlgren/one-api-endpoint-18-registries-verifying-australian-businesses-without-the-soap-and-csv-mess-1nmp</guid>
      <description>&lt;p&gt;Anyone who has tried to verify an Australian business programmatically knows the pain. There isn't one government source you can call - there are at least half a dozen, each with its own format, its own quirks, and in most cases, no API at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three registries, three different problems&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Australian Business Register (ABR) exposes ABN and GST status through a SOAP/XML web service - usable, but dated, and awkward to integrate into a modern REST stack. ASIC, which holds the actual company registrations, doesn't offer an API at all; it publishes a weekly CSV dump of several million rows. ACNC, the charities regulator, is the same story: no API, just a periodic file export. If your product needs sanctions or AML screening on top of that, you're now also pulling from DFAT's consolidated list and ASIC's banned and disqualified registers separately.&lt;/p&gt;

&lt;p&gt;For a compliance, onboarding, or billing workflow that just wants to answer "is this a real, active Australian business, and should I be worried about it," that means building and maintaining several ingestion pipelines before you've written a single line of your actual product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What AUO does differently&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AUO is a REST API (plus an MCP server and webhook layer) built as an &lt;a href="https://auo.com.au" rel="noopener noreferrer"&gt;Australian business data API&lt;/a&gt; that collapses this into one call. Send an ABN, ACN, or company name to a single resolve endpoint, and you get back a canonical record joined from eighteen free government sources, ABR, ASIC, ACNC, ORIC, GLEIF, DFAT and others, instead of a pile of registry-specific responses you have to reconcile yourself.&lt;/p&gt;

&lt;p&gt;Getting a key takes under a minute: sign up, generate a key from the dashboard, and you're given a test-prefixed key that returns synthetic sandbox data and a live-prefixed key that hits the real registers, both through the same code path. A first call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.auo.com.au/v1/resolve &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer auo_sk_your_key_here"&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;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"abn": "51824753556"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response comes back as a list of candidates rather than a single flat object, each with a match confidence and a &lt;code&gt;canonical&lt;/code&gt; block where every field, entity name, ABN, ACN, status, GST registration, is tagged with the register it came from and, where available, an &lt;code&gt;as_of&lt;/code&gt; timestamp. A screening block on the same response reports DFAT sanctions and ASIC banned-organisation status as &lt;code&gt;no_match&lt;/code&gt; or a flagged hit, and a top-level disclaimer spells out that the record excludes directors, shareholders, and beneficial ownership, because that data simply isn't in Australia's free public registers. A name ABR doesn't recognise returns a normal 200 with an empty candidate list rather than an error, so "not found" and "broken" stay distinguishable.&lt;/p&gt;

&lt;p&gt;A few other design choices stand out: conflicts between sources are surfaced rather than silently merged; screening results are always "possible match" or "no match," never "cleared," since a clean result against public data isn't a legal clearance; and bankruptcy-related signals are shown as "petition filed" rather than implying an adjudicated status. There's also a monitoring layer - subscribe to an entity and get a signed webhook when something changes, such as a deregistration or a new sanctions hit, instead of polling on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who it's for&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious users are AML/CTF and KYB teams who need to verify and screen a business before opening an account or moving money, and fintechs doing onboarding checks. But the same single call is useful anywhere a business identity needs confirming: e-commerce checkouts validating an ABN for tax treatment, real estate and legal practices running due diligence ahead of the Tranche 2 reforms taking effect from 1 July 2026, or a CRM team wanting to de-duplicate and enrich account records against a source of truth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting started&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AUO offers a free sandbox with no card required, returning realistic sample data in the same shapes as the live API, so you can build an integration before paying for anything. Paid tiers start at $49/month for 5,000 calls and 250 watched entities, scaling up to 250,000 calls and 15,000 watched entities on the top tier, with overages treated as a soft cap rather than a hard block.&lt;/p&gt;

&lt;p&gt;If you're tired of stitching SOAP calls, CSV parsers, and sanctions lists together yourself, it's worth pointing your next lookup at AUO instead of the registries directly.&lt;/p&gt;

</description>
      <category>asic</category>
      <category>abn</category>
      <category>abr</category>
      <category>api</category>
    </item>
    <item>
      <title>I Built a Local Directory Site with Astro, Airtable, and Cloudflare - Here is the Architecture</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Thu, 18 Jun 2026 23:51:26 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/i-built-a-local-directory-site-with-astro-airtable-and-cloudflare-here-is-the-architecture-3ojh</link>
      <guid>https://dev.to/mathiasahlgren/i-built-a-local-directory-site-with-astro-airtable-and-cloudflare-here-is-the-architecture-3ojh</guid>
      <description>&lt;p&gt;I've built a few local directory sites lately - the "best X in town" kind of thing - and I kept reaching for the same stack: Astro for the frontend, Airtable as the CMS, Cloudflare to host it. After the third one I realised the &lt;em&gt;architecture&lt;/em&gt; was the interesting part, not any individual site. So this is a writeup of how the pieces fit together, the decisions that actually mattered, and the two or three gotchas that cost me an afternoon each.&lt;/p&gt;

&lt;p&gt;If you're building anything that's mostly structured, read-heavy content - a directory, a catalogue, a "links" site, a small marketplace - this pattern is worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this stack for a directory
&lt;/h2&gt;

&lt;p&gt;A directory is a particular shape of website. It's a pile of structured records (each listing has a name, a category, some photos, hours, a location) that changes a few times a week, not a few times a second. Visitors read far more than they write.&lt;/p&gt;

&lt;p&gt;That shape matters, because it tells you what you &lt;em&gt;don't&lt;/em&gt; need. You don't need a PHP runtime and a database doing a query on every single page view to show a list of restaurants that barely changes. That's the WordPress model, and for this job it's mostly overhead - plus a plugin ecosystem you have to keep patched.&lt;/p&gt;

&lt;p&gt;What you actually need is three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A friendly way to &lt;strong&gt;manage&lt;/strong&gt; the data (ideally one a non-technical client can use).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;build step&lt;/strong&gt; that turns that data into fast, static HTML.&lt;/li&gt;
&lt;li&gt;Somewhere &lt;strong&gt;cheap and fast&lt;/strong&gt; to serve it from.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Airtable, Astro, and Cloudflare map onto those three jobs almost one-to-one. Let's go through each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data layer: Airtable as a build-time CMS
&lt;/h2&gt;

&lt;p&gt;The instinct when you hear "CMS" is to reach for something with an admin panel - WordPress, Strapi, a headless SaaS. But for a directory, Airtable is hard to beat, and the reason is purely about the editing experience.&lt;/p&gt;

&lt;p&gt;Your content is a table. Listings are rows. Categories are linked records. "Feature this on the homepage" is a checkbox. "Hide this" is unchecking another. That's an interface a client or a virtual assistant can use on day one without you building anything. You get a real relational-ish data model (linked records between listings and categories) &lt;em&gt;and&lt;/em&gt; a spreadsheet UI, for free.&lt;/p&gt;

&lt;p&gt;The key architectural decision: &lt;strong&gt;read Airtable at build time, not at request time.&lt;/strong&gt; A loader script runs during the build, pulls every table over the Airtable API, and hands the data to Astro to render into static pages. There's no live API call when a visitor hits the site - the content is already baked into HTML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Airtable base                 build step                 static output
┌───────────────┐        ┌────────────────────┐        ┌──────────────────┐
│ Items         │        │ loaders/airtable   │        │ HTML in dist/     │
│ Categories    │  ───►  │ • fetch all tables │  ───►  │ • /listings       │
│ Areas         │        │ • links → slugs    │        │ • /place/&amp;lt;slug&amp;gt;   │
│ + images      │        │ • download images  │        │ • /category/&amp;lt;slug&amp;gt;│
└───────────────┘        └────────────────────┘        └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consequence you have to design around: &lt;strong&gt;a content change only appears after a rebuild.&lt;/strong&gt; In dev you restart the server; in production you trigger a deploy. That sounds like a limitation, and for some apps it would be - but for a directory that updates a few times a week, it's a perfectly fine trade for never running a database. (More on automating that rebuild later.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The image gotcha that bites everyone
&lt;/h3&gt;

&lt;p&gt;Here's the one that cost me time, and it's worth the price of the whole article: &lt;strong&gt;Airtable's attachment URLs expire.&lt;/strong&gt; If you fetch a record and hot-link the image URL it gives you, your photos will silently 404 a while later.&lt;/p&gt;

&lt;p&gt;So the loader can't reference Airtable's URLs directly. Instead, at build time it &lt;strong&gt;downloads&lt;/strong&gt; every image and self-hosts the local copy. The build pulls the bytes down, drops them in a public folder, and the rendered HTML points at your own domain. Photos become stable and fast, with the side effect that adding a photo (like any content change) needs a rebuild to show up.&lt;/p&gt;

&lt;p&gt;If you take one thing from this section: any "use Airtable as a CMS" tutorial that hot-links attachment URLs is quietly broken. Download them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend: Astro, and designing for reuse
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; is the obvious fit here because directories are content-first and Astro ships zero JavaScript by default - you only opt into client-side JS where you actually need interactivity (a filter drawer, a map). For a page that's fundamentally "a styled list of records," that means very fast pages out of the box.&lt;/p&gt;

&lt;p&gt;But the more interesting decision was structural. After the first build I didn't want to &lt;em&gt;rewrite&lt;/em&gt; components every time I started a new directory in a different niche. So I wrote the whole thing in &lt;strong&gt;generic primitives&lt;/strong&gt; and pushed every niche-specific word into one config file.&lt;/p&gt;

&lt;p&gt;Nothing in the components or routes says "restaurant." The code talks about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;strong&gt;Item&lt;/strong&gt; (the thing you list)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taxonomies&lt;/strong&gt; (the ways you categorise it)&lt;/li&gt;
&lt;li&gt;an optional &lt;strong&gt;Tier&lt;/strong&gt; (a price level)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What those &lt;em&gt;mean&lt;/em&gt; lives entirely in config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;singular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Place to Eat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Places to Eat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;place&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// detail pages live at /place/&amp;lt;slug&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;taxonomies&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cuisine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cuisine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cuisines&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cuisine&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;area&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Area&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Areas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;area&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="na"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tags&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;h3&gt;
  
  
  The trick that makes re-skinning safe: keys vs labels
&lt;/h3&gt;

&lt;p&gt;This is the part I'm most pleased with, and it generalises to any config-driven system. Each taxonomy has both a &lt;strong&gt;&lt;code&gt;key&lt;/code&gt;&lt;/strong&gt; and a &lt;strong&gt;&lt;code&gt;label&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;key&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;cuisine&lt;/code&gt;, &lt;code&gt;area&lt;/code&gt;, &lt;code&gt;tag&lt;/code&gt;) is a stable internal identifier. The loader and components are wired to it. You &lt;strong&gt;never&lt;/strong&gt; change it once you have content and URLs depending on it.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;label&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;plural&lt;/code&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;code&gt;slugBase&lt;/code&gt;&lt;/strong&gt; are human-facing. They're what shows in the UI and the URLs. You rename these &lt;strong&gt;freely&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the taxonomy the code calls &lt;code&gt;cuisine&lt;/code&gt; can &lt;em&gt;display&lt;/em&gt; as "Specialty" and live at &lt;code&gt;/specialty/...&lt;/code&gt;, while every component still references the stable &lt;code&gt;cuisine&lt;/code&gt; key under the hood. To turn a restaurant directory into a hairdresser one, you change labels - &lt;code&gt;Cuisine&lt;/code&gt; → &lt;code&gt;Specialty&lt;/code&gt;, &lt;code&gt;Tags&lt;/code&gt; → &lt;code&gt;Services&lt;/code&gt;, &lt;code&gt;Place&lt;/code&gt; → &lt;code&gt;Salon&lt;/code&gt; - and rename the matching Airtable tables. No component touched, no route rewritten.&lt;/p&gt;

&lt;p&gt;That's the difference between a codebase you reuse and one you fork-and-gut every time. The vertical becomes &lt;em&gt;data, not code&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic routes from the data
&lt;/h3&gt;

&lt;p&gt;Astro's file-based routing does the heavy lifting. Two dynamic route files cover most of the site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/pages/
  [itemBase]/[slug].astro   → a listing's detail page  (/place/the-old-spence)
  [taxonomy]/[slug].astro   → a category browse page    (/cuisine/italian)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each uses &lt;code&gt;getStaticPaths()&lt;/code&gt; to enumerate every listing and every category from the build-time data and emit a static page per record. Combined with the config above, the &lt;code&gt;slugBase&lt;/code&gt; values decide the URL shapes - so when you relabel &lt;code&gt;place&lt;/code&gt; to &lt;code&gt;salon&lt;/code&gt;, the routes follow automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hosting: Cloudflare, and the "mostly static" hybrid
&lt;/h2&gt;

&lt;p&gt;Here's a nuance people miss: a directory is &lt;em&gt;almost&lt;/em&gt; fully static, but not quite. The listing pages, category pages, map, and blog are static HTML. But you usually want a few forms - "submit a business," contact, newsletter - and a form needs &lt;em&gt;something&lt;/em&gt; server-side to receive the POST.&lt;/p&gt;

&lt;p&gt;The clean answer on &lt;a href="https://www.cloudflare.com/products/workers/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt; is a hybrid. The vast majority of the site is static assets served from the edge. The handful of form endpoints run as server functions (in Astro you mark just those routes with &lt;code&gt;export const prerender = false&lt;/code&gt;). So you get static performance everywhere that matters, and a tiny bit of compute exactly where you need it - no more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Static (edge):  /  /listings  /place/*  /cuisine/*  /map  /blog/*
Server (Worker): /api/submit  /api/contact  /api/subscribe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps hosting costs at rounding-error levels - most of the traffic never touches compute - while still letting the public interact with the site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spam protection without CAPTCHA
&lt;/h3&gt;

&lt;p&gt;Those public form endpoints are the one attack surface, so they're worth hardening. I really didn't want to slap a CAPTCHA on real users, so I went with a layered guard that's invisible to humans, cheapest checks first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Honeypot&lt;/strong&gt; - a hidden field real users never see. Bots fill every field; if it's populated, the endpoint &lt;em&gt;fakes success and saves nothing&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing&lt;/strong&gt; - a hidden timestamp stamped on page load. Submissions that arrive implausibly fast (bots) or suspiciously stale (replayed pages) are dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin check&lt;/strong&gt; - posts must come from your own domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-Agent check&lt;/strong&gt; - blocks lazy scripted clients that don't look like a browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-IP rate limit&lt;/strong&gt; - using Cloudflare's rate-limiting binding, each IP gets a cap (e.g. 3 per minute per form).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The design detail I like: the bot-shaped rejections (1–4) return a &lt;strong&gt;fake success&lt;/strong&gt;, so a bot gets no signal about what tripped it. Only the rate limit and genuine validation errors show a real message. For most directories this is plenty; you can always add Cloudflare Turnstile on top if you're a bigger target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping a static site fresh
&lt;/h2&gt;

&lt;p&gt;The obvious objection to "rebuild to publish" is: isn't that annoying? It doesn't have to be. Two patterns solve it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build hook + Airtable automation.&lt;/strong&gt; Create a deploy hook, then add an Airtable automation: "when a record is created or updated → call this webhook." Now editing a listing triggers a rebuild on its own. Edit in the spreadsheet, the site updates a minute later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled rebuild.&lt;/strong&gt; A Cloudflare Cron Trigger (or any CI) that rebuilds hourly/daily.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a low-edit directory, honestly, deploying by hand when you change something is fine too. But the automation path means "static" never means "manual."&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs, honestly
&lt;/h2&gt;

&lt;p&gt;No stack is free of them, and pretending otherwise is how you end up with angry comments.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a developer's setup.&lt;/strong&gt; Initial setup and deploy are command-line work - clone, configure, deploy. Day-to-day &lt;em&gt;content&lt;/em&gt; editing is all in Airtable and needs no code, but you have to be comfortable getting it running. If you'll never open a terminal, a hosted directory builder is a better fit even at the cost of monthly fees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content is build-time.&lt;/strong&gt; Covered above - great for directories, wrong for anything needing per-request freshness (live inventory, user accounts, real-time anything).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airtable's free tier has limits.&lt;/strong&gt; Fine for hundreds of listings; if you're modelling tens of thousands of rows with heavy API traffic, reassess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the directory shape specifically, those trade-offs land on the right side of the line for me every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The pattern in one breath: &lt;strong&gt;Airtable as a build-time CMS, Astro turning that data into static pages via generic Item/Taxonomy primitives, Cloudflare serving it from the edge with a few server-side form endpoints for the interactive bits.&lt;/strong&gt; Fast, cheap, owned outright, and - if you do the key/label separation - re-skinnable to any niche without rewriting components.&lt;/p&gt;

&lt;p&gt;If you want to build this from scratch, everything above is the blueprint; none of it is secret sauce. If you'd rather not wire up the Airtable loader, the image-download step, the spam guard, and the dynamic routing yourself, I packaged this exact stack as a commercial &lt;a href="https://stackrater.io/tools/localfinds-astro-directory-template/" rel="noopener noreferrer"&gt;Astro directory template called LocalFinds&lt;/a&gt; - Astro 6, Tailwind v4, Airtable, Cloudflare, with the one-config re-skinning baked in. There's a live demo linked there if you just want to poke at the end result and see whether the architecture feels right before building your own.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Either way - build it or buy it - the stack itself is the takeaway. For read-heavy structured-content sites, this combination is genuinely hard to beat.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Happy building!&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>cloudflare</category>
      <category>tailwindcss</category>
      <category>directory</category>
    </item>
    <item>
      <title>How to Set Up a Headless WordPress Site with Astro</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Thu, 27 Jun 2024 07:26:14 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/how-to-set-up-a-headless-wordpress-site-with-astro-3a2h</link>
      <guid>https://dev.to/mathiasahlgren/how-to-set-up-a-headless-wordpress-site-with-astro-3a2h</guid>
      <description>&lt;p&gt;Here, I'm diving into the exciting world of &lt;strong&gt;headless WordPress and Astro&lt;/strong&gt;. If you're looking to combine the content management power of WordPress with the blazing-fast performance of a static site generator, you're in for a treat. Let's get started!&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;So, what's all this fuss about headless WordPress and Astro? Well, imagine taking WordPress's fantastic content management capabilities and pairing them with a modern, lightning-fast front end. That's exactly what we're doing here!&lt;/p&gt;

&lt;p&gt;Headless WordPress means we're using WordPress solely as a backend, handling all our content creation and management. Meanwhile, Astro steps in as our front-end superhero, delivering that content to users with incredible speed and flexibility.&lt;/p&gt;

&lt;p&gt;Why bother with this setup? Simple: you get the best of both worlds. Content editors can stick with the familiar WordPress interface, while developers can build a blazing-fast, SEO-friendly frontend using modern tools and frameworks. It's a win-win!&lt;/p&gt;

&lt;p&gt;Before we dive in, I've got a hot tip for you: check out &lt;a href="https://astrowp.com" rel="noopener noreferrer"&gt;AstroWP - headless WordPress starter kit&lt;/a&gt;. It's an awesome resource that can jumpstart your headless WordPress project with Astro. While we'll be building our site from scratch in this tutorial, AstroWP is definitely worth exploring if you want to hit the ground running on future projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before we jump in, let's make sure you've got everything you need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A WordPress installation (don't worry, we'll cover this)&lt;/li&gt;
&lt;li&gt;Basic knowledge of JavaScript and React (we'll be using some React components)&lt;/li&gt;
&lt;li&gt;Node.js and npm installed on your machine&lt;/li&gt;
&lt;li&gt;Familiarity with the command line (nothing too scary, I promise!)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Got all that? Great! Let's dive in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up WordPress
&lt;/h2&gt;

&lt;p&gt;First things first, let's get WordPress up and running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;If you haven't already, install WordPress on your favorite web host. There are tons of great guides out there if you need help with this step.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once WordPress is installed, log into your admin panel and head to the Plugins section. You need to install a crucial plugin called WPGraphQL. This nifty tool exposes your WordPress data through a GraphQL API, which we'll use to fetch content for our Astro site.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search for "WPGraphQL" in the plugin directory, install it, and activate it. Easy peasy!&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Now, let's create some sample content. Add a few blog posts and pages so we have something to work with. Don't stress about making it perfect – we're just testing things out.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alright, our WordPress setup is good to go. Time to switch gears and set up Astro!&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Astro
&lt;/h2&gt;

&lt;p&gt;Now for the fun part – let's get Astro up and running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open up your terminal and navigate to where you want your project to live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run the following command to create a new Astro project:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm create astro@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Follow the prompts to set up your project. When asked which template to use, choose the &lt;strong&gt;"Empty" (minimal)&lt;/strong&gt; option from the list.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once the installation is complete, cd into your new project directory and run:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will install all of the needed dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This starts the development server and gives you a local preview of your site.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open up your browser and navigate to &lt;code&gt;http://localhost:4321&lt;/code&gt;. You should see a blank Astro site. Not very exciting yet, but we're about to change that!&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up (Astro 6+):&lt;/strong&gt; If you picked the minimal template and &lt;code&gt;npm run dev&lt;/code&gt; throws a &lt;code&gt;! integrations: Required&lt;/code&gt; config error, just add an empty &lt;code&gt;integrations: []&lt;/code&gt; array to your &lt;code&gt;astro.config.mjs&lt;/code&gt;. We'll be adding the React integration there in a moment anyway, so this resolves itself in the next section.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Take a moment to explore the project structure. You'll see a &lt;code&gt;src&lt;/code&gt; folder with &lt;code&gt;pages&lt;/code&gt; and &lt;code&gt;components&lt;/code&gt; subdirectories. This is where we'll be spending most of our time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Astro to WordPress
&lt;/h2&gt;

&lt;p&gt;Now that we have both WordPress and Astro set up, it's time to introduce them to each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, let's add the React integration. Astro ships with an &lt;code&gt;astro add&lt;/code&gt; command that installs the integration, pulls in its peer dependencies, and wires up your config in one step. Run the following and accept the prompts:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npx astro add react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This automatically updates your &lt;code&gt;astro.config.mjs&lt;/code&gt; so it looks something like this:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@astrojs/react&lt;/span&gt;&lt;span class="dl"&gt;"&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;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
     &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&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;blockquote&gt;
&lt;p&gt;If you ever see a &lt;code&gt;Cannot find package 'react'&lt;/code&gt; warning when starting Astro, install the peer dependencies manually with &lt;code&gt;npm install react react-dom @types/react @types/react-dom&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;Now, we need to set up our environment variables. Create a new file in your project root called &lt;code&gt;.env&lt;/code&gt; and add the following:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;   &lt;span class="py"&gt;WP_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://your-wordpress-site.com/graphql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;https://your-wordpress-site.com&lt;/code&gt; with your actual WordPress site URL.&lt;/p&gt;

&lt;p&gt;Great job! We've now got the groundwork laid for our headless WordPress + Astro site. In the next section, we'll start fetching data from WordPress and displaying it in our Astro site. Exciting times ahead!&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching Data from WordPress
&lt;/h2&gt;

&lt;p&gt;Alright, now we're getting to the good stuff. Let's fetch some data from WordPress and display it in our Astro site:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;First, let's create a new file in the &lt;code&gt;src/pages&lt;/code&gt; directory called &lt;code&gt;index.astro&lt;/code&gt;. This will be our homepage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open up &lt;code&gt;index.astro&lt;/code&gt; and add the following code:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ---
   const response = await fetch(import.meta.env.WP_URL, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
       query: `
         query HomePagePosts {
           posts(first: 5) {
             nodes {
               title
               excerpt
               slug
             }
           }
         }
       `
     })
   });

   const json = await response.json();
   const posts = json.data.posts.nodes;
   ---

   &amp;lt;html lang="en"&amp;gt;
     &amp;lt;head&amp;gt;
       &amp;lt;meta charset="utf-8" /&amp;gt;
       &amp;lt;meta name="viewport" content="width=device-width" /&amp;gt;
       &amp;lt;title&amp;gt;My Headless WordPress Site&amp;lt;/title&amp;gt;
     &amp;lt;/head&amp;gt;
     &amp;lt;body&amp;gt;
       &amp;lt;h1&amp;gt;Welcome to My Blog&amp;lt;/h1&amp;gt;
       &amp;lt;ul&amp;gt;
         {posts.map((post) =&amp;gt; (
           &amp;lt;li&amp;gt;
             &amp;lt;h2&amp;gt;{post.title}&amp;lt;/h2&amp;gt;
             &amp;lt;p set:html={post.excerpt}&amp;gt;&amp;lt;/p&amp;gt;
             &amp;lt;a href={`/posts/${post.slug}`}&amp;gt;Read more&amp;lt;/a&amp;gt;
           &amp;lt;/li&amp;gt;
         ))}
       &amp;lt;/ul&amp;gt;
     &amp;lt;/body&amp;gt;
   &amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code does a few things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It sends a GraphQL query to our WordPress site to fetch the latest 5 posts.&lt;/li&gt;
&lt;li&gt;It then takes that data and renders it in a simple HTML structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Save the file and check out your Astro dev server. You should now see your WordPress posts displayed on the page!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pretty cool, right? We're now pulling data from WordPress and displaying it in our Astro site. But we can do even better. In the next section, we'll set up dynamic routing to create individual pages for each of our blog posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating Dynamic Routes
&lt;/h2&gt;

&lt;p&gt;Now that we've got our posts showing up on the homepage, let's create individual pages for each post:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Create a new file in &lt;code&gt;src/pages&lt;/code&gt; called &lt;code&gt;posts/[slug].astro&lt;/code&gt;. The square brackets in the filename tell Astro that this is a dynamic route.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open &lt;code&gt;[slug].astro&lt;/code&gt; and add the following code:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ---
   export async function getStaticPaths() {
     const response = await fetch(import.meta.env.WP_URL, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         query: `
           query AllPosts {
             posts {
               nodes {
                 slug
               }
             }
           }
         `
       })
     });

     const json = await response.json();
     const posts = json.data.posts.nodes;

     return posts.map((post) =&amp;gt; {
       return {
         params: { slug: post.slug },
         props: { slug: post.slug },
       };
     });
   }

   const { slug } = Astro.props;

   const response = await fetch(import.meta.env.WP_URL, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
       query: `
         query SinglePost($slug: ID!) {
           post(id: $slug, idType: SLUG) {
             title
             content
           }
         }
       `,
       variables: {
         slug: slug,
       }
     })
   });

   const json = await response.json();
   const post = json.data.post;
   ---

   &amp;lt;html lang="en"&amp;gt;
     &amp;lt;head&amp;gt;
       &amp;lt;meta charset="utf-8" /&amp;gt;
       &amp;lt;meta name="viewport" content="width=device-width" /&amp;gt;
       &amp;lt;title&amp;gt;{post.title}&amp;lt;/title&amp;gt;
     &amp;lt;/head&amp;gt;
     &amp;lt;body&amp;gt;
       &amp;lt;h1&amp;gt;{post.title}&amp;lt;/h1&amp;gt;
       &amp;lt;div set:html={post.content}&amp;gt;&amp;lt;/div&amp;gt;
       &amp;lt;a href="/"&amp;gt;Back to Home&amp;lt;/a&amp;gt;
     &amp;lt;/body&amp;gt;
   &amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code does a few important things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;getStaticPaths&lt;/code&gt; function fetches all post slugs from WordPress and tells Astro to create a page for each one.&lt;/li&gt;
&lt;li&gt;We then fetch the specific post data for each page and render it.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Now, if you click on the "Read more" links on your homepage, you should be taken to individual post pages!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Awesome work! We've now got a fully functional headless WordPress site built with Astro. Of course, there's always room for improvement. In the next sections, we'll look at styling our site and optimizing its performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling Your Astro Site
&lt;/h2&gt;

&lt;p&gt;Now that we've got our content displaying correctly, let's make it look a bit nicer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Astro supports several styling options out of the box. For this tutorial, we'll use Astro's built-in CSS support.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Create a new file in &lt;code&gt;src/styles&lt;/code&gt; called &lt;code&gt;global.css&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add some basic styles to &lt;code&gt;global.css&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;   &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Arial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2c3e50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3498db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;underline&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;ol&gt;
&lt;li&gt;Now, let's import this CSS file in our pages. In both &lt;code&gt;index.astro&lt;/code&gt; and &lt;code&gt;posts/[slug].astro&lt;/code&gt;, add this line in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; section:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   &amp;lt;link rel="stylesheet" href="/styles/global.css" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Refresh your browser, and you should see a much nicer-looking site!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remember, this is just a starting point. Feel free to expand on these styles and make the site your own!&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimizing Performance
&lt;/h2&gt;

&lt;p&gt;One of Astro's big selling points is its focus on performance. Let's take advantage of some of Astro's features to make our site even faster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Astro uses partial hydration, which means it only sends JavaScript to the browser when it's needed. This is great for performance, but we haven't actually used any client-side JavaScript yet. If you need interactivity, you can use Astro's client directives like &lt;code&gt;client:load&lt;/code&gt; or &lt;code&gt;client:idle&lt;/code&gt; on your components.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;For image optimization, Astro has a built-in Image component. Let's use it for our post thumbnails. First, install the sharp package:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm &lt;span class="nb"&gt;install &lt;/span&gt;sharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Before we can render a thumbnail, we need to actually fetch the featured image data. Update the GraphQL query in your &lt;code&gt;index.astro&lt;/code&gt; to include the &lt;code&gt;featuredImage&lt;/code&gt; field:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HomePagePosts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
       &lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;featuredImage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
           &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="n"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;
           &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
       &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Then, in your &lt;code&gt;index.astro&lt;/code&gt; file, import the Image component and use it for your post thumbnails:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ---
   import { Image } from 'astro:assets';
   // ... rest of your frontmatter code
   ---

   &amp;lt;!-- In your HTML, inside the posts.map() loop --&amp;gt;
   {post.featuredImage &amp;amp;&amp;amp; (
     &amp;lt;Image
       src={post.featuredImage.node.sourceUrl}
       width={300}
       height={200}
       alt={post.title}
       inferSize
     /&amp;gt;
   )}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: Remote images from WordPress need their domain authorized in &lt;code&gt;astro.config.mjs&lt;/code&gt; under the &lt;code&gt;image.domains&lt;/code&gt; (or &lt;code&gt;image.remotePatterns&lt;/code&gt;) setting before Astro will optimize them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Astro also automatically optimizes your CSS and HTML. It removes unused CSS and minifies your HTML in production builds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;We're in the home stretch! Let's get your site deployed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, build your site with:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;This will create a &lt;code&gt;dist&lt;/code&gt; folder with your production-ready site.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You can deploy this folder to any static hosting service. Netlify, Vercel, and Cloudflare Pages are popular options that work great with Astro.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you're using Netlify, you can simply drag and drop your &lt;code&gt;dist&lt;/code&gt; folder onto their site to deploy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;For automated deployments, you can set up a GitHub repository for your project and connect it to your hosting service. Then, every time you push to your main branch, your site will automatically rebuild and deploy.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;And there you have it! We've successfully set up a &lt;a href="https://stackrater.io/tools/best-wordpress-blog-alternatives/" rel="noopener noreferrer"&gt;headless WordPress blog with Astro&lt;/a&gt;. We've covered everything from initial setup to deployment, touching on data fetching, routing, styling, and performance optimization along the way.&lt;/p&gt;

&lt;p&gt;Remember, this is just the beginning. There's so much more you can do with this setup. You could add custom post types, implement search functionality, or even turn your site into a full-fledged e-commerce platform.&lt;/p&gt;

&lt;p&gt;I hope this tutorial has been helpful and has sparked some ideas for your own projects. Happy coding!&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;p&gt;Before we wrap up, let's quickly address some common issues you might run into:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;CORS errors: If you're getting CORS errors when trying to fetch data from WordPress, you may need to install a CORS plugin in WordPress or configure your server to allow cross-origin requests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GraphQL errors: Double-check your query syntax if you're getting GraphQL errors. The WPGraphQL plugin provides a GraphiQL interface in the WordPress admin panel where you can test your queries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Astro build problems: If you're having issues building your Astro site, make sure all your dependencies are up to date. You can also try clearing your &lt;code&gt;.astro&lt;/code&gt; cache folder.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remember, the Astro and WordPress communities are very helpful. If you run into any issues you can't solve, don't hesitate to reach out for help!&lt;/p&gt;

&lt;p&gt;P.S. if you haven't already, you definitely need to &lt;a href="https://github.com/emdash-cms/emdash" rel="noopener noreferrer"&gt;check out EmDash&lt;/a&gt;, a CMS built on Astro and Cloudflare.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article has been updated from its original version. Changes include: clarifying the "Empty" (minimal) Astro template name, switching React setup to the recommended &lt;code&gt;npx astro add react&lt;/code&gt; command, adding a fix for the Astro 6 &lt;code&gt;integrations: Required&lt;/code&gt; error, and correcting the featured-image example to fetch the &lt;code&gt;featuredImage&lt;/code&gt; field it relied on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>headless</category>
      <category>wordpress</category>
      <category>astro</category>
      <category>cms</category>
    </item>
  </channel>
</rss>
