<?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: Menelaos Vergis</title>
    <description>The latest articles on DEV Community by Menelaos Vergis (@menelaos_vergis_045957e7a).</description>
    <link>https://dev.to/menelaos_vergis_045957e7a</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%2F1537632%2F7234e3ab-c637-4fae-ab18-d23e845ca571.png</url>
      <title>DEV Community: Menelaos Vergis</title>
      <link>https://dev.to/menelaos_vergis_045957e7a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/menelaos_vergis_045957e7a"/>
    <language>en</language>
    <item>
      <title>A feedback screen for Android and Flutter with no server: what testing taught us</title>
      <dc:creator>Menelaos Vergis</dc:creator>
      <pubDate>Fri, 06 Nov 2026 03:30:46 +0000</pubDate>
      <link>https://dev.to/menelaos_vergis_045957e7a/a-feedback-screen-for-android-and-flutter-with-no-server-what-testing-taught-us-2cpk</link>
      <guid>https://dev.to/menelaos_vergis_045957e7a/a-feedback-screen-for-android-and-flutter-with-no-server-what-testing-taught-us-2cpk</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://bootform.com/blog/feedback-screen-android-flutter-no-server" rel="noopener noreferrer"&gt;BootForm blog&lt;/a&gt;. I work on BootForm, the form backend these samples send to; the samples are free and MIT licensed.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; an app can send feedback straight to a form backend, so you don't need a server of your own. Our open-source &lt;a href="https://github.com/BootForm/in-app-feedback" rel="noopener noreferrer"&gt;&lt;code&gt;in-app-feedback&lt;/code&gt;&lt;/a&gt; samples do it in one file per platform: device info attached, an offline queue, and an optional screenshot. The biggest lessons from testing: save the report before you send it, expect Android's force stop to pause your queue, give every report an ID so a retry can't send it twice, decide what to do for each kind of answer, and be honest that an app can't keep its form ID secret.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The samples send to BootForm, but the lessons apply to any backend you'd point an app at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;A "Send feedback" or "Report a bug" screen for an Android or Flutter app, where each report arrives in a dashboard (and by email, Slack or Discord if you like) with what you need to act on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Device info, attached automatically:&lt;/strong&gt; app version and build, OS version, device model, locale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An offline queue:&lt;/strong&gt; a report written in airplane mode is kept and sent later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An optional screenshot&lt;/strong&gt; of the screen the user was on, which they can see and leave out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not an SDK. Each platform is one file you copy into your app, plus a small demo app: Kotlin with Jetpack Compose and WorkManager for Android, and Dart for Flutter. The whole API is one HTTP request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST https://f.bootform.com/{your-form-id}
Content-Type: multipart/form-data
Accept: application/json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We tested both demo apps on an Android emulator (API 36) against the live API, then tried to break them. This is what we learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Save first, send second
&lt;/h2&gt;

&lt;p&gt;The obvious design sends the report when the user presses Send. That's the wrong moment to depend on the network: people write feedback when something's going wrong, and a bad connection is often part of it.&lt;/p&gt;

&lt;p&gt;So both samples &lt;strong&gt;save the report on the device first&lt;/strong&gt;, then try to send it. The button never waits on the network, and the screen says what actually happened. On Android:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Saved. It will be sent as soon as you're online, even if you close the app.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and only "Sent. Thank you!" once the server has answered. Telling a user "Sent" while the report is sitting in a queue is a small lie that turns into a real one if the queue is lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Android: WorkManager survives the system, not a force stop
&lt;/h2&gt;

&lt;p&gt;On Android the queue is a WorkManager job with a network constraint and exponential backoff, starting at 30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneTimeWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeedbackWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Constraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requiredNetworkType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NetworkType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CONNECTED&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBackoffCriteria&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BackoffPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EXPONENTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInputData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nc"&gt;WorkManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We wrote a report in airplane mode and then tried the ways an app gets closed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The system kills the process&lt;/strong&gt; (app in the background, &lt;code&gt;adb shell am kill &amp;lt;package&amp;gt;&lt;/code&gt;). Network back on: the report went out without the app being opened. That's the point of WorkManager.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force stop&lt;/strong&gt; (Settings, Apps, Force stop, or &lt;code&gt;adb shell am force-stop &amp;lt;package&amp;gt;&lt;/code&gt;). Network back on: nothing, until we opened the app again, and then it sent straight away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second isn't a bug. A force stop puts the app in Android's "stopped state": its scheduled jobs are cancelled until the user launches it again. WorkManager reschedules them on that next launch. &lt;code&gt;adb shell dumpsys jobscheduler | grep &amp;lt;package&amp;gt;&lt;/code&gt; shows whether your job is still there.&lt;/p&gt;

&lt;p&gt;What we changed: nothing in the code, but the README says it plainly, and the retries are bounded. WorkManager caps the backoff at 5 hours, and the sample gives up after 15 tries, about a day and a half of trying while online, instead of retrying forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Flutter: send when the app is open, and keep it simple
&lt;/h2&gt;

&lt;p&gt;Flutter can run background work, but on iOS that means native setup and platform rules that are easy to get wrong in a sample people copy. So the Flutter version makes a different trade: each report is saved as a JSON file (plus the image) in the app's support folder, and the queue is sent &lt;strong&gt;when the app starts and whenever it comes back to the foreground&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;BootFormFeedback&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;_lifecycle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppLifecycleListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onResume:&lt;/span&gt; &lt;span class="n"&gt;BootFormFeedback&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't send while the app is closed, and reports older than 7 days are dropped. In return it needs only four common packages (&lt;code&gt;http&lt;/code&gt;, &lt;code&gt;path_provider&lt;/code&gt;, &lt;code&gt;device_info_plus&lt;/code&gt;, &lt;code&gt;package_info_plus&lt;/code&gt;) and no background setup on either platform. For feedback, "sent next time you open the app" is usually good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Retries send duplicates, unless every report has an ID
&lt;/h2&gt;

&lt;p&gt;A queue that retries is "at least once". If the server stores the report but the app never sees the answer (the connection drops, the app is killed mid-request), the retry sends it again, and you get the same bug report twice, in your inbox twice.&lt;/p&gt;

&lt;p&gt;The first samples worked around it with a &lt;code&gt;report_id&lt;/code&gt; field made on the device, so duplicates could at least be spotted by eye. A reader in r/FlutterDev asked the obvious question: why not let the server drop them? So BootForm now accepts an &lt;strong&gt;idempotency key&lt;/strong&gt;, and both samples send the report's ID as one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"report_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRequestProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;it&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;The first request with a key is stored as usual. A repeat answers &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;"duplicate": true&lt;/code&gt; and stores nothing, sends no second notification and doesn't count against the quota. If you build your own queue against any API, make the ID on the device, when the report is saved, not when it's sent: that's what makes every retry carry the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Decide what each answer means
&lt;/h2&gt;

&lt;p&gt;"Retry on failure" isn't enough, because some failures will never succeed and some only need part of the report dropped. Each sample has one small, pure function that turns the server's answer into one of four outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;hadScreenshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;299&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sent&lt;/span&gt;
    &lt;span class="n"&gt;hadScreenshot&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nc"&gt;SCREENSHOT_REFUSED&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DropScreenshot&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;408&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RetryLater&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Rejected&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The server says&lt;/th&gt;
&lt;th&gt;The app does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2xx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deletes the report and its screenshot from the device.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The file isn't accepted (plan limit, file too large, form not claimed yet)&lt;/td&gt;
&lt;td&gt;Sends the report again without the screenshot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Too many requests, a &lt;code&gt;5xx&lt;/code&gt;, a timeout, no network&lt;/td&gt;
&lt;td&gt;Keeps it and tries later.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The form is gone or disabled, or the request is invalid&lt;/td&gt;
&lt;td&gt;Gives up: sending the same report again would fail the same way.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "drop the screenshot" row is the one testing proved right. Our first test report, with a screenshot, was refused: the test form hadn't been claimed yet, and an unclaimed form never accepts files (so nobody can use a stranger's form ID as free file storage). The sample sent it again without the image, and it arrived. Without that row the whole report would have been rejected and lost, over an attachment. The same happens on a plan without file uploads, so the text always gets through.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;classify&lt;/code&gt; is pure, it's unit tested without a device. An outside contributor added the remaining cases to both test suites during Hacktoberfest.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Screenshots: take them early, shrink them, let the user decide
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Take it before the feedback screen opens&lt;/strong&gt;, so it shows what the user was looking at, not the feedback form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink it on the device.&lt;/strong&gt; Android sends a JPEG with its longest side at 1,280 pixels, usually 100 to 400 KB; Flutter sends a PNG of the same size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show it, with a checkbox to leave it out.&lt;/strong&gt; A screenshot can contain anything that was on screen, including personal data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On BootForm, file uploads start on the Starter plan. On Free the report is still sent, just without the image, because of the rule above.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Attach the details nobody types
&lt;/h2&gt;

&lt;p&gt;Users rarely write "Pixel 7, Android 16, version 2.4.1". The samples send it for them, as ordinary fields, so they show up next to the message and in exports:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;app_version&lt;/code&gt;, &lt;code&gt;app_build&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;2.4.1&lt;/code&gt;, &lt;code&gt;241&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;os_version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Android 16 (API 36)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;device&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Google Pixel 7&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;locale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;en-GB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;platform&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;android&lt;/code&gt; or &lt;code&gt;ios&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Locale earns its place quickly: "the text is cut off" is very often a long translation. Add your own fields the same way (the current screen, a user ID, a feature flag): they're just more keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Be honest: an app can't keep its form ID secret
&lt;/h2&gt;

&lt;p&gt;A website form can be locked to your domain. An app can't: a native app sends no &lt;code&gt;Origin&lt;/code&gt; header, so a form used from an app has to allow requests from anywhere. And the form ID ships inside your app, where anyone who unpacks it can read it and post junk straight to the API.&lt;/p&gt;

&lt;p&gt;What still protects you: per-IP and per-form rate limits, your monthly quota, and rules that mark matching reports as spam (for example, a message containing &lt;code&gt;http&lt;/code&gt;). A CAPTCHA like Turnstile is built for web pages, so it doesn't help here. And the worst case is spam, never a leak: the ID only lets someone send to your form, not read from it. For feedback, that trade-off is usually fine.&lt;/p&gt;

&lt;p&gt;Two habits that follow from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a separate form for app feedback&lt;/strong&gt;, so a spam wave never mixes with your website's contact form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claim the form before you ship.&lt;/strong&gt; A form ID works before anyone claims it (BootForm holds up to 10 reports for 48 hours), and the first person to claim it owns it. Claim it while you're still developing, and it's yours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we haven't tested yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Flutter sample on iOS.&lt;/strong&gt; The code is the same, but we've only run it on Android.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A screenshot accepted on a paid plan, end to end.&lt;/strong&gt; The refusal path is tested; the success path only by the server's own tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reboot with reports queued.&lt;/strong&gt; WorkManager's documentation says scheduled work survives one; we haven't checked it ourselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A native SwiftUI version&lt;/strong&gt; waits until it can be built and run on a real Mac.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try any of these, an issue on the repo is very welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Generate your own form ID (any random UUID, for example from our &lt;a href="https://bootform.com/uuidgenerator" rel="noopener noreferrer"&gt;UUID generator&lt;/a&gt;) and paste it into the file. Never use one copied from a tutorial, this one included.&lt;/li&gt;
&lt;li&gt;Claim it at &lt;code&gt;app.bootform.com/claim/{your-id}&lt;/code&gt; with a free account.&lt;/li&gt;
&lt;li&gt;Copy the one file for your platform from &lt;a href="https://github.com/BootForm/in-app-feedback" rel="noopener noreferrer"&gt;&lt;code&gt;BootForm/in-app-feedback&lt;/code&gt;&lt;/a&gt; and add its dependencies: WorkManager on Android, the four packages above on Flutter.&lt;/li&gt;
&lt;li&gt;Call it from your own feedback button. The demo apps show a plain screen you can restyle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The repo is MIT licensed, so the files can go into closed-source apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I add a feedback form to an Android app without a backend?
&lt;/h3&gt;

&lt;p&gt;Send each report as a multipart POST to a form backend, from a WorkManager job so it survives a bad connection. The &lt;a href="https://github.com/BootForm/in-app-feedback/tree/main/compose" rel="noopener noreferrer"&gt;Compose sample&lt;/a&gt; is one Kotlin file that does this with device info and an optional screenshot.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add a bug report screen to a Flutter app?
&lt;/h3&gt;

&lt;p&gt;Copy &lt;a href="https://github.com/BootForm/in-app-feedback/tree/main/flutter" rel="noopener noreferrer"&gt;&lt;code&gt;bootform_feedback.dart&lt;/code&gt;&lt;/a&gt;, call &lt;code&gt;BootFormFeedback.send(...)&lt;/code&gt; from your screen, and call &lt;code&gt;BootFormFeedback.flush()&lt;/code&gt; when the app starts and resumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if the user is offline?
&lt;/h3&gt;

&lt;p&gt;The report is saved on the device and sent later: on Android by WorkManager, even with the app closed (unless the user force-stops it); on Flutter the next time the app is opened.&lt;/p&gt;

&lt;h3&gt;
  
  
  How is this different from a crash reporter?
&lt;/h3&gt;

&lt;p&gt;A crash reporter catches what the app does wrong without the user. A feedback screen catches what the user wants to tell you: a confusing screen, a missing feature, "it's slow on my phone". They work well side by side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I get the reports in Slack, Discord or email?
&lt;/h3&gt;

&lt;p&gt;Yes. That's set on the form in BootForm, so the app code doesn't change.&lt;/p&gt;

</description>
      <category>android</category>
      <category>flutter</category>
      <category>kotlin</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to add a contact form to a static website: every option compared</title>
      <dc:creator>Menelaos Vergis</dc:creator>
      <pubDate>Wed, 28 Oct 2026 10:35:03 +0000</pubDate>
      <link>https://dev.to/menelaos_vergis_045957e7a/how-to-add-a-contact-form-to-a-static-website-every-option-compared-1404</link>
      <guid>https://dev.to/menelaos_vergis_045957e7a/how-to-add-a-contact-form-to-a-static-website-every-option-compared-1404</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://bootform.com/blog/how-to-add-a-contact-form-to-a-static-website" rel="noopener noreferrer"&gt;BootForm blog&lt;/a&gt;. I work on BootForm, one of the options compared here.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; a static site can't receive a form by itself, so the form posts to a form backend that emails you the message. On any host, BootForm and Web3Forms give the most free submissions, 250 a month. BootForm also includes Cloudflare Turnstile, Discord notifications and an MCP server for AI coding agents on its free plan, and you don't need an account to start. If your site is already on Netlify, Netlify Forms is free with no submission cap. Forminit and Basin accept file uploads for free. Or write a small function yourself, if you want full control and don't mind looking after it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every number below comes from each provider's own pricing page or docs, checked on 23 October 2026. Prices change, so check the provider's page before you commit. And yes, we make one of these (BootForm), so we've tried hard to say where each of the others is the better choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't a static site send a form by itself?
&lt;/h2&gt;

&lt;p&gt;A static site is a folder of HTML files. Nothing runs on the server when a visitor presses "Send", so there's nowhere for the message to go. That's what makes static sites fast, cheap and hard to break, and it's also the one thing they can't do alone.&lt;/p&gt;

&lt;p&gt;The fix is the same in every option here. Your form's &lt;code&gt;action&lt;/code&gt; points at someone else's server, which receives the POST, filters out spam, and passes the message on: usually by email, sometimes to Slack, Discord, a webhook or a spreadsheet. The form itself stays plain HTML:&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://forms.example.com/your-form-id"&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;"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;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;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;What differs is how many messages you get for free, how spam is stopped, whether files can be attached, and whether you need an account (or a particular host) before it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison at a glance
&lt;/h2&gt;

&lt;p&gt;Free plans first, since that's where most small sites stay:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Free a month&lt;/th&gt;
&lt;th&gt;Free forms&lt;/th&gt;
&lt;th&gt;Free spam protection&lt;/th&gt;
&lt;th&gt;Free uploads&lt;/th&gt;
&lt;th&gt;MCP for AI agents&lt;/th&gt;
&lt;th&gt;First paid plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://bootform.com/pricing" rel="noopener noreferrer"&gt;BootForm&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;Unlimited (fair use)&lt;/td&gt;
&lt;td&gt;Honeypot, Turnstile, spam folder&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, every plan&lt;/td&gt;
&lt;td&gt;$8/mo, 2,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://web3forms.com/pricing" rel="noopener noreferrer"&gt;Web3Forms&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Spam filter, hCaptcha, honeypot (&lt;strong&gt;Turnstile: paid only&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;$12.42/mo, 10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://forminit.com/pricing/" rel="noopener noreferrer"&gt;Forminit&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Filters, Turnstile, reCAPTCHA, hCaptcha&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;$15.83/mo, 5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://formspree.io/plans" rel="noopener noreferrer"&gt;Formspree&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;ML filter, reCAPTCHA&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;$10/mo, 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://usebasin.com/pricing" rel="noopener noreferrer"&gt;Basin&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Filtering, reCAPTCHA, hCaptcha, Turnstile&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;Not yet (rolling out on paid plans)&lt;/td&gt;
&lt;td&gt;$12.50/mo, 250&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.netlify.com/manage/forms/setup/" rel="noopener noreferrer"&gt;Netlify Forms&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;No cap (Netlify sites only)&lt;/td&gt;
&lt;td&gt;Per site&lt;/td&gt;
&lt;td&gt;Akismet, honeypot, reCAPTCHA&lt;/td&gt;
&lt;td&gt;8 MB per submission&lt;/td&gt;
&lt;td&gt;Netlify's, site-wide&lt;/td&gt;
&lt;td&gt;Netlify Personal, $9/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own function&lt;/td&gt;
&lt;td&gt;Up to you&lt;/td&gt;
&lt;td&gt;Up to you&lt;/td&gt;
&lt;td&gt;Up to you&lt;/td&gt;
&lt;td&gt;Up to you&lt;/td&gt;
&lt;td&gt;Up to you&lt;/td&gt;
&lt;td&gt;About $5 to $25/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Paid prices are per month, billed yearly where there is a yearly price, with the submissions a month they include. Turnstile means Cloudflare Turnstile with your own (free) keys. Forminit was formerly called Getform.&lt;/p&gt;

&lt;p&gt;Once you outgrow the free plan, the price per message varies a lot more than the headline prices suggest. Here are the first paid plans, cheapest first, paid yearly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;First paid plan&lt;/th&gt;
&lt;th&gt;Per month&lt;/th&gt;
&lt;th&gt;Included&lt;/th&gt;
&lt;th&gt;Cost per 1,000&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BootForm Starter&lt;/td&gt;
&lt;td&gt;$8.00&lt;/td&gt;
&lt;td&gt;2,500&lt;/td&gt;
&lt;td&gt;$3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formspree Personal&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;$50.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web3Forms Pro&lt;/td&gt;
&lt;td&gt;$12.42&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$1.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basin Starter&lt;/td&gt;
&lt;td&gt;$12.50&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;$50.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forminit Pro&lt;/td&gt;
&lt;td&gt;$15.83&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;$3.17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;BootForm Starter is the cheapest way to start paying, with more than twelve times the volume of the next cheapest. Web3Forms Pro costs less per message if you need thousands a month.&lt;/p&gt;

&lt;p&gt;Netlify isn't in this table because its forms aren't charged per submission at all on its current plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  BootForm: the most free features, no account needed to start
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;We make BootForm, so read this section with that in mind.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;BootForm's free plan has 250 submissions a month for your whole account, with as many forms as you like. It also includes things the others charge for or leave out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Turnstile&lt;/strong&gt; with your own (free) Turnstile keys. On Web3Forms it needs a paid plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord notifications&lt;/strong&gt; as well as email. Web3Forms, Forminit and Basin keep chat notifications for paid plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A honeypot that's on by default&lt;/strong&gt;, and a spam folder: rejected messages are kept, not lost. If a form's inbox starts to look like spam, BootForm emails you about it; it never moves a message because of what it says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dashboard&lt;/strong&gt; to search, star and export your messages (CSV or JSON), kept for 30 days in full and six months in all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation rules&lt;/strong&gt; (required fields, types, lengths) and an IP blocklist, on every plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two more things set it apart. First, nobody else here lets you start without an account: pick your own form ID (any &lt;a href="https://bootform.com/uuidgenerator" rel="noopener noreferrer"&gt;UUID&lt;/a&gt;), put it in the form's address, and send a test. Up to 10 submissions are held for 48 hours until you claim the form. Claim it before the page goes public, because whoever claims a form first owns it. Second, the &lt;a href="https://bootform.com/docs/mcp-and-ai-agents" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; is built for forms and included on every plan, Free too: if you build your site with Claude Code, Cursor or another AI coding agent, you sign in once in the browser (or create a free account in the same step), and the agent can then create the form, connect your email and read the submissions. There's no API key to copy.&lt;/p&gt;

&lt;p&gt;When you outgrow Free, Starter is the cheapest first paid plan here at $8 a month (yearly) for 2,500 submissions, and adds file uploads, Slack, Telegram and webhooks.&lt;/p&gt;

&lt;p&gt;Where it's weaker: no file uploads on the free plan, no content filter that reads the message (see &lt;a href="https://bootform.com/blog/how-to-add-a-contact-form-to-a-static-website#how-do-the-spam-protections-compare" rel="noopener noreferrer"&gt;spam protection&lt;/a&gt; below), and it's newer than the others here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you want the most a free plan gives on any host, to try a form before signing up for anything, or an AI agent is doing the setup. &lt;a href="https://bootform.com/docs/getting-started" rel="noopener noreferrer"&gt;Getting started&lt;/a&gt; takes about a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Web3Forms: 250 free a month, email only on the free plan
&lt;/h2&gt;

&lt;p&gt;Web3Forms gives you 250 submissions a month and unlimited forms for free, the same allowance as BootForm, and it works wherever your site is hosted. You sign up with your email, get an access key, and put it in a hidden field. The free plan includes their spam filter, hCaptcha (with a site key they provide) and a honeypot, plus a custom thank-you page and CSV export.&lt;/p&gt;

&lt;p&gt;What's paid: Cloudflare Turnstile and reCAPTCHA, webhooks, chat and Sheets integrations, and restricting a form to your own domain all need a paid plan. The cheapest is a yearly-only Starter plan for students, new startups and non-profits, by application, which includes all of those but only one active integration. File uploads, autoresponders and the submissions API need Pro, at $149 a year, which is also the cheapest per message in this comparison by a wide margin. One thing to check for yourself: their FAQ says "We do not store any form submissions of our users", while their pricing page lists 30 days of submission history on the free plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; email is all you need and hCaptcha is enough for spam, or you expect thousands of messages a month: its Pro plan is the cheapest per message here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Netlify Forms: the easy answer if you're already on Netlify
&lt;/h2&gt;

&lt;p&gt;Netlify Forms is free with no submission cap on Netlify's current credit-based plans. Its &lt;a href="https://docs.netlify.com/manage/accounts-and-billing/billing/billing-for-credit-based-plans/billing-faq-for-credit-based-plans/" rel="noopener noreferrer"&gt;billing FAQ&lt;/a&gt; says it plainly: "Form usage is free and unlimited." You turn on form detection in the Netlify dashboard, add a &lt;code&gt;data-netlify="true"&lt;/code&gt; attribute to your form, deploy, and Netlify finds the form in your HTML at build time. Every submission goes through Akismet, a proper content-based spam filter, and you can add a honeypot or reCAPTCHA on top.&lt;/p&gt;

&lt;p&gt;The catch is in the same sentence: it only works for sites deployed on Netlify, because the form is detected during Netlify's build. A form your JavaScript renders after the page loads needs a hidden static copy so the build can find it. And if your team runs out of Netlify credits, Netlify pauses your projects, and a paused project "will not receive new web requests, web traffic, or form submissions." The form goes down with the site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; your site is already on Netlify and you don't need much beyond email notifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forminit (formerly Getform): file uploads on the free plan
&lt;/h2&gt;

&lt;p&gt;Forminit is Getform under a new name, and it says so: "the same independent form-backend service operating since 2015." Its free plan is 100 submissions a month on a single form, and it's one of only two options here that accept file uploads for free, with 100 MB of storage. Free submissions are kept until you delete them, rather than for a fixed window.&lt;/p&gt;

&lt;p&gt;Webhooks, Slack, Zapier, Google Sheets, Discord notifications, autoresponders and the REST API start on Pro, at $190 a year for 5,000 submissions a month. For spam, its docs cover a honeypot and your own Turnstile, reCAPTCHA or hCaptcha keys, which Forminit checks on its server, and the pricing page doesn't limit any of them to a paid plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; visitors need to attach a file and you only need one form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formspree: the long-established choice
&lt;/h2&gt;

&lt;p&gt;Formspree is the name most tutorials use, and it has a long list of integrations. The free plan is 50 submissions a month with unlimited forms and a 30-day archive. It includes Formshield, its machine-learning spam filter, and your own reCAPTCHA. Unusually, Slack, Discord, Telegram and WhatsApp notifications are free too.&lt;/p&gt;

&lt;p&gt;The limits show up quickly. Personal is $120 a year for 200 submissions a month, which works out to $50 per 1,000. Webhooks, Zapier, autoresponders and API access start on Professional ($240 a year), and a custom honeypot or your own spam rules need Business ($720 a year).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you need a specific integration only Formspree has, or chat notifications on a low-volume form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Basin: Zapier and uploads on the free plan
&lt;/h2&gt;

&lt;p&gt;Basin's free plan has 50 submissions a month on one form, with 30 days of history, basic spam filtering, 100 MB of file uploads and, unusually, Zapier. reCAPTCHA, hCaptcha and Turnstile are all included on every plan, free too, the widest CAPTCHA choice here; a custom honeypot and the duplicate filter start on Growth. Every paid plan comes with a 14-day free trial.&lt;/p&gt;

&lt;p&gt;Paid plans step up gradually: Starter at $12.50 a month (yearly) for 250 submissions and 3 forms, Growth at $24.17 a month for 1,000 submissions with webhooks, Slack and autoresponders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you want Zapier or file uploads without paying, at low volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you build your own form handler?
&lt;/h2&gt;

&lt;p&gt;You can, and at small volume it's nearly free. A Cloudflare Worker handles 100,000 requests a day on its free plan, and Resend sends 3,000 emails a month (100 a day) for free. About thirty lines of code connect the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A Cloudflare Worker that emails each form submission through Resend.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&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;request&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="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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &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;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;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thanks!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// honeypot: bots fill it, people don't&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&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;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;email&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;message&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;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;message&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Please fill in every field.&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;sent&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.resend.com/emails&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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="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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Contact form &amp;lt;form@your-domain.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;you@your-domain.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;reply_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New message from your website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="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;sent&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sorry, that didn't send. 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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;THANK_YOU_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;303&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// a full URL, e.g. https://your-domain.com/thanks&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;That works, but look at what it leaves out. There's no record of messages, so if an email is lost, so is the message. A failed send isn't retried. The honeypot is the only spam protection; adding Turnstile means verifying its token on the server too. There's no limit on how often one visitor can post. And you have to verify a sending domain with Resend, keep the API key secret, and notice when something breaks. None of that is hard on its own. Together it's the work a form backend does for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick it if&lt;/strong&gt; you enjoy this kind of thing, need something no service offers, or must keep every message on infrastructure you control.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the spam protections compare?
&lt;/h2&gt;

&lt;p&gt;Spam is where free plans differ most. There are three kinds of protection, and they stop different bots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A honeypot&lt;/strong&gt; is a hidden field that people never see and many bots fill in. It costs visitors nothing and stops most of the cheap bots. Every option here supports one, though Formspree only lets you set your own on Business, and Basin from Growth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CAPTCHA&lt;/strong&gt; (Turnstile, hCaptcha, reCAPTCHA) asks the browser to prove it's a person, usually without a puzzle. It stops bots that post straight to your form's address. Turnstile is free from Cloudflare; whether you can use it on a free plan depends on the provider (BootForm, Basin and Forminit: yes; Web3Forms: paid plans only).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A content filter&lt;/strong&gt; (Netlify's Akismet, Formspree's Formshield) reads the message and scores it. It catches spam a person typed by hand, which the other two can't. The trade-off is the occasional real message marked as spam, so check the spam folder now and then.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with a honeypot. If bots still get through, add a CAPTCHA. Our &lt;a href="https://bootform.com/docs/spam-protection" rel="noopener noreferrer"&gt;spam protection guide&lt;/a&gt; walks through both on BootForm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you choose?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want the most free messages and features, on any host:&lt;/strong&gt; BootForm (Web3Forms matches its 250 a month, without free Turnstile or Discord).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to try it before signing up, or an AI agent is building your site:&lt;/strong&gt; BootForm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You expect thousands of messages a month:&lt;/strong&gt; Web3Forms Pro is the cheapest per message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visitors need to attach files, and you won't pay:&lt;/strong&gt; Forminit or Basin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your site is on Netlify:&lt;/strong&gt; Netlify Forms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a particular integration:&lt;/strong&gt; Formspree probably has it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want to own everything:&lt;/strong&gt; a small function and an email API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you choose, the HTML is nearly the same, so switching later is mostly a matter of changing the form's &lt;code&gt;action&lt;/code&gt; and the name of the hidden field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I add a contact form to GitHub Pages?
&lt;/h3&gt;

&lt;p&gt;Yes. GitHub Pages only serves static files, so the form posts to an outside form backend, any of the services above except Netlify Forms, which needs Netlify hosting. Our &lt;a href="https://bootform.com/blog/free-website-blog-search-contact-form" rel="noopener noreferrer"&gt;free website guide&lt;/a&gt; builds a GitHub Pages site with a working form from start to finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free form backend with no limits?
&lt;/h3&gt;

&lt;p&gt;Netlify Forms has no submission cap on Netlify's current plans, but only for sites hosted on Netlify. Elsewhere, every free plan has a monthly limit: 250 on BootForm and Web3Forms, 100 on Forminit and 50 on Formspree and Basin, as of October 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need JavaScript for a contact form?
&lt;/h3&gt;

&lt;p&gt;No. A plain HTML form with &lt;code&gt;method="POST"&lt;/code&gt; works with every option here. JavaScript only comes in if you want to send the form without leaving the page, or to show a CAPTCHA widget.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which form backend works with an AI coding agent?
&lt;/h3&gt;

&lt;p&gt;Any of them, if the agent writes the HTML and you create the form by hand. For the agent to set the form up itself, the backend needs an MCP server. BootForm's is included on every plan, Free too: after one browser sign-in, the agent creates the form, connects your email and checks for messages. Basin lists one for its paid plans, but says it's still rolling it out, and Netlify's covers forms along with the rest of a Netlify site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is my form's address a secret?
&lt;/h3&gt;

&lt;p&gt;Not really: it's in your page's HTML for anyone to read. That's why spam protection matters, and why the providers that offer it let you restrict a form to your own domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you'd like to try BootForm, &lt;a href="https://bootform.com/docs/getting-started" rel="noopener noreferrer"&gt;getting started&lt;/a&gt; takes about a minute, and you don't need an account until you want to read your messages. There are step-by-step guides for &lt;a href="https://bootform.com/docs/framework-vanilla-js" rel="noopener noreferrer"&gt;plain HTML and JavaScript&lt;/a&gt;, &lt;a href="https://bootform.com/docs/framework-jekyll" rel="noopener noreferrer"&gt;Jekyll&lt;/a&gt;, &lt;a href="https://bootform.com/docs/framework-react" rel="noopener noreferrer"&gt;React&lt;/a&gt;, &lt;a href="https://bootform.com/docs/framework-vue" rel="noopener noreferrer"&gt;Vue&lt;/a&gt; and &lt;a href="https://bootform.com/docs/framework-angular" rel="noopener noreferrer"&gt;Angular&lt;/a&gt;. Starting a site from scratch? The &lt;a href="https://bootform.com/blog/free-website-blog-search-contact-form" rel="noopener noreferrer"&gt;free website guide&lt;/a&gt; gets you a blog with a working contact form on GitHub Pages in about fifteen minutes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>beginners</category>
      <category>jamstack</category>
    </item>
    <item>
      <title>A fast website with a blog, search and a contact form, hosted free</title>
      <dc:creator>Menelaos Vergis</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:17:59 +0000</pubDate>
      <link>https://dev.to/menelaos_vergis_045957e7a/a-fast-website-with-a-blog-search-and-a-contact-form-hosted-free-30g0</link>
      <guid>https://dev.to/menelaos_vergis_045957e7a/a-fast-website-with-a-blog-search-and-a-contact-form-hosted-free-30g0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://bootform.com/blog/free-website-blog-search-contact-form" rel="noopener noreferrer"&gt;BootForm blog&lt;/a&gt;. I work on BootForm, the form backend used in step 4; the templates are free and MIT licensed.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In short:&lt;/strong&gt; copy the free &lt;code&gt;vitepress-blog&lt;/code&gt; template on GitHub, edit it in the browser, and GitHub Pages publishes it at no cost. You get a blog with tags, search and an RSS feed, plus a contact form whose messages BootForm emails to you on its free plan. It takes about fifteen minutes and nothing to install.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can have a website with a blog, tags, full-text search, an RSS feed and a contact form that actually sends you the messages, without paying for hosting and without installing anything. This post walks through it start to finish, in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you end up with
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A blog with a post list, tag pages and an RSS feed, all generated from a folder of text files.&lt;/li&gt;
&lt;li&gt;Search across every post, running in the visitor's browser.&lt;/li&gt;
&lt;li&gt;A contact page whose messages land in your inbox and in a dashboard.&lt;/li&gt;
&lt;li&gt;A live address like &lt;code&gt;https://yourname.github.io/vitepress-blog/&lt;/code&gt;, served by GitHub Pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zklv7i83m8dgv41yt8u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zklv7i83m8dgv41yt8u.webp" alt="The finished blog's home page: a photo hero with the blog's name, " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Search is built in. Press &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;Ctrl K&lt;/code&gt; on any page and start typing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqtjvmy6ypmgv59hza21.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqtjvmy6ypmgv59hza21.webp" alt="The search box open over the post list, with the word " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is &lt;a href="https://bootform.github.io/vitepress-blog/" rel="noopener noreferrer"&gt;the finished example&lt;/a&gt;, if you want to click around before you start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a static site enough for a blog?
&lt;/h2&gt;

&lt;p&gt;For most blogs, yes. A static site is a folder of pre-built HTML files with no server or database behind it, so there is nothing to patch, nothing to break into, and every page loads as fast as a plain HTML file. That is also why it can be hosted for free.&lt;/p&gt;

&lt;p&gt;The one thing a static site cannot do by itself is receive a form, because there is no server to send it to. A form backend fills that gap: the form posts to the backend's address, and the backend emails each message on to you. That is the part BootForm does here, on its free plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a static site is the wrong choice:&lt;/strong&gt; if you sell products, need customer logins, or the person updating the site will never touch a text file, use a shop platform or a CMS instead. A static blog suits people who are happy to write a post as a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two templates, pick one
&lt;/h2&gt;

&lt;p&gt;Both are free, open source (MIT), and ship with a working contact form.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://github.com/BootForm/first-website" rel="noopener noreferrer"&gt;first-website&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://github.com/BootForm/vitepress-blog" rel="noopener noreferrer"&gt;vitepress-blog&lt;/a&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;One page, plain HTML&lt;/td&gt;
&lt;td&gt;A full blog built with VitePress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog, tags, RSS, search&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Browser only, about 15 minutes&lt;/td&gt;
&lt;td&gt;Browser only (or locally with Node.js)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;Your first page on the internet&lt;/td&gt;
&lt;td&gt;A site you will keep adding posts to&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this post builds &lt;code&gt;vitepress-blog&lt;/code&gt;. If one page is all you need, &lt;code&gt;first-website&lt;/code&gt;'s README walks you through the same steps for a single page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Make your own copy
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://github.com/BootForm/vitepress-blog" rel="noopener noreferrer"&gt;github.com/BootForm/vitepress-blog&lt;/a&gt;, click &lt;strong&gt;Use this template&lt;/strong&gt;, then &lt;strong&gt;Create a new repository&lt;/strong&gt;. Keep the name &lt;code&gt;vitepress-blog&lt;/code&gt; and make it public.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5qeoi75962kryfbt73ho.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5qeoi75962kryfbt73ho.webp" alt="The vitepress-blog repository on GitHub with the " width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keeping the name saves you a step. If you rename it, the README's Step 1 lists the two lines to change to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Turn on GitHub Pages
&lt;/h2&gt;

&lt;p&gt;In your new repository, go to &lt;strong&gt;Settings&lt;/strong&gt;, then &lt;strong&gt;Pages&lt;/strong&gt;, and set &lt;strong&gt;Source&lt;/strong&gt; to &lt;strong&gt;GitHub Actions&lt;/strong&gt;. From now on, every change you save builds the site and publishes it by itself. The template already includes the workflow that does this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xi64lv2szze1rm0s7l5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xi64lv2szze1rm0s7l5.webp" alt="GitHub's Pages settings with Source set to " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Make it yours, in the browser
&lt;/h2&gt;

&lt;p&gt;On your repository's main page, press the full-stop key (&lt;code&gt;.&lt;/code&gt;). GitHub opens &lt;code&gt;github.dev&lt;/code&gt;, a full code editor in the browser.&lt;/p&gt;

&lt;p&gt;Look for &lt;code&gt;CHANGE ME&lt;/code&gt;. The places that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docs/.vitepress/config.mts&lt;/code&gt;: the blog's title and description.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/index.md&lt;/code&gt;: the home page text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/.vitepress/theme/style.css&lt;/code&gt;: your brand colour.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/.vitepress/authors.ts&lt;/code&gt;: your name, instead of the example authors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/generate-rss.mjs&lt;/code&gt;: &lt;code&gt;SITE_URL&lt;/code&gt;. Put your own GitHub username in place of &lt;code&gt;yourname&lt;/code&gt;, otherwise the feed's links point at someone else's address.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every post is a Markdown file in &lt;code&gt;docs/posts/&lt;/code&gt;. The top of each one says its title, date, author and tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;page&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Your post title&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-10-15&lt;/span&gt;
&lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;priya&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;some-tag&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;another-tag&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To write a new post, copy one of the example files and change it. The post list, the tag pages, the RSS feed and the search index all rebuild from whatever is in that folder, so there is no other file to update.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycbuq7k2bgsxhwi30jds.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycbuq7k2bgsxhwi30jds.webp" alt="The generated post list: each post with a photo, its date and author, a one-line description and its tags" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Don't commit yet. The contact form goes in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Connect the contact form
&lt;/h2&gt;

&lt;p&gt;The contact page (&lt;code&gt;docs/contact.md&lt;/code&gt;) is already built. It just needs somewhere to send messages.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vwdfodgxz8f29ldvtv5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vwdfodgxz8f29ldvtv5.webp" alt="The template's contact page: a " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open BootForm's &lt;a href="https://bootform.com/uuidgenerator" rel="noopener noreferrer"&gt;UUID generator&lt;/a&gt; and copy the ID it gives you. That ID is your form. There is nothing to sign up for yet.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;docs/contact.md&lt;/code&gt;, find &lt;code&gt;__YOUR_FORM_ID__&lt;/code&gt; in the form's &lt;code&gt;action&lt;/code&gt; and paste your ID over it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A BootForm form ID is a UUID you generate yourself. Pasting it into a form's &lt;code&gt;action&lt;/code&gt; makes the form work straight away, and the first person to claim the ID receives everything sent to it. So use the ID that was generated for you, never one you saw in a tutorial (this one included).&lt;/p&gt;

&lt;p&gt;Leave the hidden &lt;code&gt;honeypot&lt;/code&gt; field in the form alone. It is BootForm's spam trap: bots fill it in, people never see it, and while the honeypot is on (it is by default), a submission without it is treated as spam. More in &lt;a href="https://bootform.com/docs/spam-protection" rel="noopener noreferrer"&gt;Spam protection&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Now open the &lt;strong&gt;Source Control&lt;/strong&gt; panel on the left, write a message such as "Make it mine", and click &lt;strong&gt;Commit &amp;amp; Push&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Go live and send yourself a message
&lt;/h2&gt;

&lt;p&gt;Open the &lt;strong&gt;Actions&lt;/strong&gt; tab of your repository. The deploy run takes about a minute; when it turns green, your site is at &lt;code&gt;https://yourname.github.io/vitepress-blog/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Open the contact page, fill it in and press Send. Because nobody owns this form yet, BootForm holds the message and shows you a page offering to claim the form. Create a free account there (signing up with GitHub is quickest) and the form is yours: the waiting message shows up in your dashboard, and new messages are emailed to the address you signed up with.&lt;/p&gt;

&lt;p&gt;Until it is claimed, a form holds up to 10 messages for 48 hours, so claim it before you share the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much does it cost?
&lt;/h2&gt;

&lt;p&gt;Nothing, for a blog like this one. GitHub Pages hosts a public repository's site for free, within &lt;a href="https://docs.github.com/en/pages/getting-started-with-github-pages/github-pages-limits" rel="noopener noreferrer"&gt;soft limits&lt;/a&gt; of 1 GB per site and 100 GB of bandwidth a month. BootForm's free plan adds the contact form, with 250 submissions a month, unlimited forms, email and Discord notifications, and spam protection. See &lt;a href="https://bootform.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for the paid plans.&lt;/p&gt;

&lt;p&gt;A custom domain is optional. The site works on its &lt;code&gt;github.io&lt;/code&gt; address; your own domain costs whatever your registrar charges, and &lt;a href="https://bootform.com/docs/computer-setup" rel="noopener noreferrer"&gt;Setting up your computer&lt;/a&gt; covers pointing it at GitHub Pages.&lt;/p&gt;

&lt;p&gt;One thing worth knowing: GitHub's own documentation says Pages is not meant to run an online business, an e-commerce site or software as a service. A personal blog, a portfolio or a small business's information site is the kind of thing it is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is GitHub Pages really free?
&lt;/h3&gt;

&lt;p&gt;Yes, for a public repository. GitHub's published limits are 1 GB per site and a soft limit of 100 GB of bandwidth a month, which is far more than a small blog uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a static site have a working contact form?
&lt;/h3&gt;

&lt;p&gt;Yes. The form posts to a form backend instead of your own server. With BootForm you point the form's &lt;code&gt;action&lt;/code&gt; at &lt;code&gt;https://f.bootform.com/&lt;/code&gt; followed by your form ID, and each message is emailed to you and kept in a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to install anything?
&lt;/h3&gt;

&lt;p&gt;No. Everything in this post happens in the browser: GitHub's web pages and the &lt;code&gt;github.dev&lt;/code&gt; editor. GitHub Actions builds the site for you. You can still run it on your own computer with Node.js if you prefer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to messages before I sign up?
&lt;/h3&gt;

&lt;p&gt;BootForm holds up to 10 messages for 48 hours on a form nobody has claimed yet. Claim the form with a free account and they are delivered to you; after 48 hours unclaimed, they are deleted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use my own domain?
&lt;/h3&gt;

&lt;p&gt;Yes. GitHub Pages supports custom domains with free HTTPS. You buy the domain from any registrar and point it at GitHub; &lt;a href="https://bootform.com/docs/computer-setup" rel="noopener noreferrer"&gt;Setting up your computer&lt;/a&gt; walks through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add more to the pages.&lt;/strong&gt; &lt;a href="https://bootform.github.io/site-sections/" rel="noopener noreferrer"&gt;site-sections&lt;/a&gt; has 27 copy-paste sections (heroes, pricing, FAQs, footers) written to work inside these templates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let an AI agent do the editing.&lt;/strong&gt; Each template ships with an &lt;code&gt;AGENTS.md&lt;/code&gt;, and the &lt;a href="https://github.com/BootForm/site-skills" rel="noopener noreferrer"&gt;Claude Code plugins&lt;/a&gt; can add sections, new kinds of content and a theme for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More templates.&lt;/strong&gt; &lt;a href="https://bootform.com/docs/templates" rel="noopener noreferrer"&gt;Starter templates&lt;/a&gt; lists the rest, including a one-page business site that ends on your own domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using another site builder?&lt;/strong&gt; The &lt;a href="https://bootform.com/docs/framework-jekyll" rel="noopener noreferrer"&gt;Jekyll&lt;/a&gt;, &lt;a href="https://bootform.com/docs/framework-vue" rel="noopener noreferrer"&gt;Vue&lt;/a&gt; and &lt;a href="https://bootform.com/docs/framework-vanilla-js" rel="noopener noreferrer"&gt;plain HTML&lt;/a&gt; guides show the same form in each.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with &lt;a href="https://github.com/BootForm/vitepress-blog" rel="noopener noreferrer"&gt;the template&lt;/a&gt;: the whole thing takes about fifteen minutes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
      <category>github</category>
    </item>
  </channel>
</rss>
