# Static Forms blog (full content) Every published post on https://www.staticforms.dev/blog as Markdown, newest first. The documentation in full is at https://www.staticforms.dev/llms-full.txt. ## Turnstile Test Keys, Localhost and Token Errors, Fixed Source: https://www.staticforms.dev/blog/turnstile-test-keys-token-errors Date: 2026-10-27 Author: Static Forms Team Turnstile test keys let you run a Cloudflare Turnstile form on `localhost`, in CI, or on a preview URL without registering a domain or solving a real challenge. They also let you force the failure cases on purpose, which is the only reliable way to see what your form does when a token is missing, expired or rejected. Below you'll find Cloudflare's test site keys and secret keys, which pairs work together, two ways to test on localhost, and a map from the error text you see (from the widget, from Cloudflare's siteverify API, from Static Forms, or from your own backend) to a cause and a fix. The Static Forms examples assume a form with Turnstile turned on in its Security tab, which is available on Starter and up. If you haven't added Turnstile to a form yet, start with the [Turnstile setup guide](/docs/forms/security/turnstile) and the [Cloudflare Turnstile best practices](/blog/cloudflare-turnstile-best-practices) post. This page assumes the widget is already on the page. ## Cloudflare Turnstile test keys Cloudflare publishes dummy keys for testing. Copied from Cloudflare's [Turnstile testing docs](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) on October 27, 2026: | Key type | Value | What it does | |----------|-------|--------------| | Site key | `1x00000000000000000000AA` | Always passes (visible widget) | | Site key | `2x00000000000000000000AB` | Always fails (visible widget) | | Site key | `1x00000000000000000000BB` | Always passes (invisible widget) | | Site key | `2x00000000000000000000BB` | Always fails (invisible widget) | | Site key | `3x00000000000000000000FF` | Forces an interactive challenge (visible widget) | | Secret key | `1x0000000000000000000000000000000AA` | Always passes validation | | Secret key | `2x0000000000000000000000000000000AA` | Always fails validation | | Secret key | `3x0000000000000000000000000000000AA` | Returns a "token already spent" error | A test site key produces the dummy token `XXXX.DUMMY.TOKEN.XXXX`. Per Cloudflare, the test keys work on any domain, including `localhost`, `127.0.0.1`, `0.0.0.0` and any development domain. ![Matrix of Turnstile key pairs: a test site key with the 1x test secret passes, a production site key with a production secret passes, a test site key with a production secret is rejected, and a production site key with the 1x test secret accepts any token](/blog/turnstile-test-keys-token-errors-infographic.png) ## Which test key pairs work together The site key and the secret key live in different places. The site key goes in your page. The secret key goes wherever siteverify runs, which with Static Forms is the form's Security tab. Here's how the pairs behave: - **Test site key + test secret key.** Works. The widget issues the dummy token and the `1x…` secret accepts it. The `2x…` and `3x…` secrets fail it on purpose. - **Test site key + production secret key.** Fails. Cloudflare says production secret keys reject the dummy token. - **Production site key + `1x…` test secret.** Don't rely on this one to fail. Cloudflare's docs say test secrets reject real tokens, but when we called siteverify on October 27, 2026, the `1x…AA` secret returned `success: true` for an arbitrary string that wasn't the dummy token. If submissions pass, that doesn't prove your keys match. So when you swap the site key in your HTML for a test key, swap the secret key too, and keep test secrets on a separate test form. A form with the `1x…` secret saved on it accepts any non-empty `cf-turnstile-response`, so any bot can pass it. ## How to test Cloudflare Turnstile on localhost You have two options, and they test different things. **Option 1: test keys.** Put `1x00000000000000000000AA` in your page and save `1x0000000000000000000000000000000AA` as the Turnstile secret on a test form. Nothing to register, and it works on any port. Then swap the secret to `2x…`, `3x…` and a made-up string to exercise your failure handling. This tests your integration code, not Cloudflare's risk engine. **Option 2: a separate development widget.** Create a second widget in the Cloudflare dashboard, add `localhost` under Hostname Management, and use its real keys on a test form. This issues real tokens with real expiry, which is what you want when debugging a token that expires on long forms. Cloudflare recommends that production site keys don't allow local domains, so keep `localhost` off your production widget. If you skip both and load your production site key on `localhost`, the widget fails with client error `110200` (Domain not authorized). ## Turnstile token errors: lookup table Find the exact text you're seeing. The tables are split by the layer that produces each message: Static Forms, Cloudflare's siteverify API, the widget in the browser, or your own backend. ### Errors from Static Forms When a form has Turnstile selected and a secret key saved, `POST https://api.staticforms.dev/submit` returns HTTP 403 with one of these. Requests made with `fetch` get JSON (`success`, `error`, `code`). A url-encoded HTML form post that asks for `text/html` gets the `error` text as the response body, without the code. A `multipart/form-data` post, which any form with a file input sends, gets the JSON. | `error` text | `code` | Cause | Fix | |---|---|---|---| | `Turnstile verification failed. Please complete the captcha.` | `captcha_required` | No non-empty `cf-turnstile-response` field reached the API | Send the token as `cf-turnstile-response`. Check the request payload in DevTools. | | `Turnstile token expired or was already used. Please refresh the captcha and submit again.` | `captcha_expired_or_reused` | Cloudflare returned `timeout-or-duplicate` or `invalid-input-response`: the token is older than 300 seconds, was already verified, or is malformed. The `2x…` and `3x…` test secrets also land here. | Reset the widget and get a new token. Mint tokens at submit time (execute mode), not on page load. | | `Turnstile verification failed. Please try again.` | `captcha_failed` | Any other failure: a wrong or invalid secret key (`invalid-input-secret`), or siteverify could not be reached | Copy the secret from the widget in the Cloudflare dashboard again and save it on the form. | One more message has no `code`: HTTP 500 with `Turnstile verification failed due to server error. Please try again.` Static Forms returns it when an unexpected error stops the check on its side, for example when it can't read the form's stored secret. Nothing in your token or keys causes it, so retry the submission. Two details from the API code: - If the same field arrives twice (the widget's own hidden input plus a token you appended to `FormData`), Static Forms verifies the **last non-empty** value. An empty hidden input won't hide your token, but a stale token appended after the fresh one will be the one checked. `formData.set()` avoids the question. - If the form has no Turnstile secret saved, or CAPTCHA is turned off in its Security tab, Static Forms skips verification and accepts the submission. A test that "passes" may not be checking anything, so save the `2x…` secret once and confirm you get a 403 with `captcha_expired_or_reused`. ### Errors from Cloudflare's siteverify API You see these in the `error-codes` array if you call siteverify yourself, or in your server logs. Descriptions are Cloudflare's, from the [server-side validation docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/): | Code | Cloudflare's description | Usual cause | |---|---|---| | `missing-input-response` | Response parameter was not provided | The token never made it into the request | | `invalid-input-response` | Token is invalid, malformed, or expired | The token expired, was cut short, or was copied wrong. The `2x…` always-fail test secret also returns it. | | `timeout-or-duplicate` | Token has already been validated | The same token was verified twice (retries, double submits, or the `3x…` test secret) | | `invalid-input-secret` | Secret key is invalid or expired | The secret is wrong, for example mistyped or copied only in part | | `missing-input-secret` | Secret parameter not provided | Your backend didn't send the secret | | `bad-request` | Request is malformed | Check the request format and parameters | | `internal-error` | Internal error occurred | Retry the request | Tokens are valid for 300 seconds, can be validated once, and can be up to 2048 characters long. ### Errors from the widget in the browser These come back through the widget's `error-callback`, before any token exists. From Cloudflare's [client-side error codes](https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/): | Code | Meaning | Fix | |---|---|---| | `110100` | Invalid sitekey | Check the site key in the Cloudflare dashboard | | `110110` | Sitekey not found | Check its spelling and the widget configuration | | `110200` | Domain not authorized | Add the current hostname under Hostname Management, or use a test site key | | `300*`, `600*` | Generic challenge failure | Cloudflare treated the visitor as a bot. Retry, then check browser extensions and VPNs, as Cloudflare's [challenge troubleshooting page](https://developers.cloudflare.com/cloudflare-challenges/troubleshooting/challenge-solve-issues/) suggests | ### "The cf-turnstile-response field is required." Neither Cloudflare nor Static Forms returns this sentence. It's the shape of a server-side `required` validation rule; Laravel, for example, prints `The field is required.` If you see it, your own backend received a request without the field. The usual causes: - The widget never rendered, so its hidden input was never created. - A custom `fetch` builds the body by hand (often JSON) and leaves the token out. - A wrapper library renamed the response field or turned it off. The fix is the same as for `captcha_required`: confirm the request payload contains a non-empty `cf-turnstile-response`. ## Complete Working Example This page uses the always-pass test site key, so it works on `localhost` with no Cloudflare setup. Save `1x0000000000000000000000000000000AA` as the Turnstile secret on a **test** form in the Static Forms dashboard (Turnstile is available on Starter and up), then replace `YOUR_TEST_FORM_API_KEY` with that form's API key. ```html Turnstile test form

``` Serve it from a local server rather than opening the file directly, for example `python3 -m http.server 8080` in the file's folder, then open `http://localhost:8080`. Then run four test cases by changing only the secret key saved on the test form. Keep the Network tab open: 1. `1x0000000000000000000000000000000AA`: the submission goes through and arrives in your inbox. 2. `2x0000000000000000000000000000000AA`: Cloudflare answers `invalid-input-response`, so Static Forms returns 403 with `captcha_expired_or_reused`. The example retries once with a new token, so you see two `submit` requests, and the page then shows "Turnstile token expired or was already used. Please refresh the captcha and submit again." 3. `3x0000000000000000000000000000000AA`: Cloudflare answers `timeout-or-duplicate`. The result matches case 2: two requests, then the same message. 4. A made-up secret, such as the `1x…` secret with one character changed: Cloudflare answers `invalid-input-secret`, so Static Forms returns 403 with `captcha_failed`. The example doesn't retry that code, so you see one request and "Turnstile verification failed. Please try again." No test secret produces `captcha_failed`. Case 4 is the one to run if you want to see that message. Before you go live, put your real site key back in the page and use the form whose Security tab holds the matching real secret. ## Troubleshooting checklist Work through these in order. 1. **Is verification on?** The form needs Turnstile selected and a secret key saved in its Security tab. Without both, Static Forms doesn't check the token. 2. **Is the token in the request?** Open DevTools, find the `submit` request, and look for a non-empty `cf-turnstile-response` in the payload. 3. **Do the keys match?** The site key in your page and the secret on the form must come from the same widget, or both be test keys. 4. **Is the token fresh?** Tokens last 300 seconds and verify once. Render with `execution: 'execute'` and call `turnstile.reset()` before every new attempt, including after a failed submit. 5. **Is the hostname allowed?** A `110200` error means the page's hostname isn't on the widget. Use test keys or a separate development widget for `localhost` and previews. For general submission debugging (CORS, redirects, status codes), see the [debugging guide](/docs/debugging) and the [troubleshooting page](/docs/troubleshooting). ## Next step Set up a test form with the `1x…AA` secret, run the example above, then save the `2x…` secret and a mistyped secret in turn, so you've seen both failure messages your visitors can get. The [Turnstile setup guide](/docs/forms/security/turnstile) has the HTML, React and Next.js versions of the widget code for production. --- ## Add a Contact Form to Eleventy Without Writing a Backend Source: https://www.staticforms.dev/blog/eleventy-contact-form Date: 2026-08-19 Author: Hussain Fakhruddin Eleventy turns templates into static HTML. That keeps an 11ty site fast and easy to deploy, but the generated site cannot process a contact form or send an email by itself. You could add a serverless function and an email service. For a basic contact form, a hosted form endpoint is a smaller system to maintain. The visitor submits directly to Static Forms, which validates the request, stores the submission, and sends the notification email. This guide gives you: - a complete Nunjucks contact-page template; - a custom thank-you page; - honeypot spam protection; - deployment and production test steps; - fixes for common Eleventy form problems. No client-side JavaScript is required. > **Prefer a working project?** The [complete Eleventy example](https://github.com/static-forms/static-forms-examples/tree/main/apps/eleventy-example) is on GitHub and deploys to Vercel in one click. It's one of [13 framework examples](https://github.com/static-forms/static-forms-examples) covering Next.js, Nuxt, SvelteKit, Astro, Angular and more. ## How it works The submission path is short: 1. Eleventy builds `contact.njk` into static HTML. 2. Your host serves the generated contact page. 3. The browser posts the fields to `https://api.staticforms.dev/submit`. 4. Static Forms delivers the message to your inbox and redirects the visitor to your thank-you page. The form works on any host that can serve an Eleventy build, including Cloudflare Pages, Netlify, Vercel, GitHub Pages, and a regular web server. ## What you need Before editing the site, make sure you have: - an existing Eleventy project; - a [Static Forms account](https://www.staticforms.dev/register); - a form API key from the [Static Forms dashboard](https://www.staticforms.dev/app/forms); - the production URL where the Eleventy site will be deployed. The form API key appears in the generated HTML. That is expected for a browser-submitted form. Treat it as a form identifier rather than a server secret. You can prevent other websites from using it by enabling [domain restriction](/docs/forms/security/domain-restriction), available from the Starter plan up. ## Step 1: create the Eleventy contact template Create `contact.njk` in your Eleventy input directory. Many projects use the repository root or a folder such as `src` for input files. If your project already has a base layout, use this page template: ```html --- layout: layouts/base.njk title: Contact us description: Send our team a message. permalink: /contact/index.html ---

Contact us

Send a message and we will reply by email.

``` Replace: - `YOUR_API_KEY` with the key shown for this form in Static Forms; - `https://example.com/thank-you/` with the absolute production URL of your thank-you page. The `permalink` front matter makes the output path predictable. Eleventy will generate `_site/contact/index.html` when `_site` is your configured output directory. ### What the hidden fields do The `apiKey` field associates the submission with your Static Forms form. `replyTo` set to `@` tells Static Forms to use the submitted `email` value as the Reply-To address, so replying to the notification reaches the visitor. The honeypot is an ordinary text field moved out of view. Basic bots often fill it, while a person does not. Static Forms silently rejects submissions when the `honeypot` field contains a value. The [honeypot guide](/docs/forms/security/honeypot) explains how to test it. ## Step 2: style the form Add these rules to your existing stylesheet: ```css .contact-card { width: min(100%, 42rem); margin-inline: auto; padding: clamp(1.5rem, 5vw, 3rem); background: #ffffff; border: 1px solid #e5e7eb; border-radius: 1rem; box-shadow: 0 1rem 3rem rgb(15 23 42 / 8%); } .field { margin-top: 1.25rem; } .field label { display: block; margin-bottom: 0.4rem; font-weight: 650; } .field input, .field textarea, .contact-card button { box-sizing: border-box; width: 100%; font: inherit; } .field input, .field textarea { padding: 0.8rem; color: #111827; background: #ffffff; border: 1px solid #9ca3af; border-radius: 0.5rem; } .field input:focus, .field textarea:focus { outline: 3px solid rgb(254 91 91 / 25%); border-color: #d93636; } .field textarea { resize: vertical; } .contact-card button { margin-top: 1.5rem; padding: 0.85rem 1rem; color: #ffffff; background: #d93636; border: 0; border-radius: 0.5rem; font-weight: 700; cursor: pointer; } .contact-card button:hover { background: #b91c1c; } .honeypot { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; } ``` Do not remove the `label` elements to make the design shorter. Placeholders disappear while a person is typing and do not replace accessible labels. If the stylesheet sits outside Eleventy's input directory or uses a path that Eleventy does not copy automatically, configure it as a [passthrough copy](https://www.11ty.dev/docs/copy/). Then confirm the final CSS file exists inside the generated output directory. ## Step 3: add the thank-you page Create `thank-you.njk` in the same input directory: ```html --- layout: layouts/base.njk title: Message received permalink: /thank-you/index.html eleventyExcludeFromCollections: true ---

Thanks, your message was sent.

We will reply as soon as we can.

Return to the home page

``` `eleventyExcludeFromCollections` keeps the utility page out of collections that automatically generate post lists or navigation. Your project may handle navigation separately, so check the generated menu before deploying. A dedicated thank-you URL is also useful for analytics. Count a view of `/thank-you/` as a completed form submission, while remembering that a visitor can open the URL directly. ## Step 4: build and inspect the generated HTML Run the build command used by your project. A standard Eleventy project commonly uses: ```bash npx @11ty/eleventy ``` For a local development server: ```bash npx @11ty/eleventy --serve ``` Eleventy configuration differs between projects. Check the `dir.input` and `dir.output` values in your [Eleventy configuration file](https://www.11ty.dev/docs/config/) if the pages appear in an unexpected directory. Before deploying, inspect the generated contact page and confirm that: - `YOUR_API_KEY` is no longer present; - the form action is exactly `https://api.staticforms.dev/submit`; - the method is `POST`; - the thank-you URL points to production; - every visible input has a unique `name`; - the stylesheet loads from the generated site. Avoid inserting the API key through a runtime environment variable on the hosting platform unless your build command actually reads that variable. Eleventy runs at build time. A variable that is configured but never referenced by the build will not change the generated HTML. ## Step 5: deploy and test the production page Deploy the same output directory you inspected locally. With the default configuration, this is usually `_site`. Run one complete test from the public URL: 1. Open `/contact/` in a private browser window. 2. Submit a recognizable message such as `Eleventy production form test`. 3. Confirm the browser reaches `/thank-you/`. 4. Check that the submission appears in the Static Forms inbox. 5. Confirm the notification reaches the recipient email. 6. Reply to the notification and verify that the submitted address is used. 7. Repeat once on a phone or narrow browser window. Testing only on localhost misses problems caused by production domain restrictions, Content Security Policy headers, incorrect output directories, and stale deployments. ## Restrict submissions to your domain Once the production form works, open the form's Security settings and add the site's hostname without a protocol or path: ```text example.com ``` Add the host-provided domain too if people can access it directly, for example: ```text example-project.pages.dev ``` If you enable domain restriction before local testing is finished, allow localhost temporarily. Remove access for unused preview hostnames after the site is stable. ## Common Eleventy contact-form problems | Symptom | Likely cause | What to check | | --- | --- | --- | | `/contact/` returns 404 | The file is outside the Eleventy input directory, ignored, or has a different permalink | Inspect Eleventy configuration and confirm `_site/contact/index.html` exists | | The page loads without styles | The stylesheet was not copied into the output | Add a passthrough-copy rule and inspect the generated CSS URL | | The API reports an invalid key | The placeholder survived the build or the key belongs to another form | Search the generated HTML for `YOUR_API_KEY` and copy the current key from the dashboard | | The form works locally but fails in production | Domain restriction does not include the production or preview hostname | Add each approved hostname without `https://` | | The thank-you page returns 404 | `redirectTo` and the Eleventy permalink do not match | Compare the absolute redirect URL with the generated output path | | A field is missing from the email | The input has no `name`, or two controls reuse the same name | Give each submitted field a stable, unique `name` | | The browser reports a CSP violation | The site's `form-action` policy blocks the external endpoint | Allow `https://api.staticforms.dev` in the production CSP | | Spam gets through | Honeypots mainly stop basic bots | Add reCAPTCHA v2 (any plan), or [Cloudflare Turnstile](/docs/forms/security/turnstile), hCaptcha, reCAPTCHA v3, or ALTCHA (Starter and up) | ## If your site has a strict Content Security Policy A Content Security Policy can prevent the browser from sending the form even when the markup is correct. If the console mentions a `form-action` violation, allow the Static Forms endpoint: ```text Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.dev ``` Merge that source into the policy you already use. Do not replace an existing policy with the short example without checking the scripts, images, fonts, and connections required by the rest of the site. ## Production checklist Before linking to the form from your navigation, verify that: - the contact and thank-you pages exist in the deployed output; - the generated form contains the correct API key; - the redirect uses an absolute HTTPS production URL; - visible controls have labels, names, and clear focus styles; - a normal submission leaves the honeypot empty; - the CSP permits `https://api.staticforms.dev` in `form-action`; - production hostnames are present in domain restriction settings; - a real submission appears in both the Static Forms inbox and the recipient inbox. The result is still a static Eleventy site. Eleventy handles the templates and build, your host serves the generated HTML, and Static Forms handles the submission workflow without adding a backend to the project. --- ## How to Add a Contact Form to Cloudflare Pages Source: https://www.staticforms.dev/blog/cloudflare-pages-contact-form Date: 2026-08-17 Author: Hussain Fakhruddin Cloudflare Pages can deploy an HTML site across Cloudflare's network, but static files cannot receive a form submission or send an email by themselves. You could add a Pages Function and an email provider. For a contact form, that is often more infrastructure than the job needs. This tutorial uses a regular HTML form and Static Forms. The browser sends the submission to Static Forms, which validates it and delivers it to your inbox. Your Cloudflare Pages project stays static. By the end, you will have: - a contact form that works without client-side JavaScript; - email delivery and a stored submission in the Static Forms inbox; - a honeypot field for basic spam filtering; - a custom thank-you page; - a deployment and testing checklist for Cloudflare Pages. ## How the submission flows The setup has three parts: 1. Cloudflare Pages serves your contact page. 2. The visitor's browser posts the form to `https://api.staticforms.dev/submit`. 3. Static Forms processes the submission and sends the notification email. Cloudflare Pages does not need to run a function in this flow. That also means there is no server code, email credential, or runtime secret to maintain in the Pages project. ## Prerequisites You need: - a Cloudflare Pages project, or a static site ready to deploy; - a free [Static Forms account](https://www.staticforms.dev/register); - an API key from your form in the [Static Forms dashboard](https://www.staticforms.dev/app/forms). A Static Forms form API key is used in browser-facing HTML, so visitors can see it in the page source. It is an identifier, not a server password. If you want to stop another website from reusing it, enable [domain restriction](/docs/forms/security/domain-restriction) (Starter plan and up) after your site is deployed. ## Step 1: create the contact page Create `contact.html` in the directory Cloudflare Pages publishes. Depending on your site, that might be the repository root, `public`, or a generated output directory such as `dist`. Paste this complete example: ```html Contact us

Contact us

Send a message and we will reply by email.

``` Replace two values before deploying: - `YOUR_API_KEY` with the key shown for your form in Static Forms; - `YOUR-PROJECT` with your Cloudflare Pages project name. If your site uses a custom domain, put that domain in `redirectTo` instead. The value must be an absolute URL, for example `https://example.com/thank-you.html`. ### Why these hidden fields are present `apiKey` tells Static Forms which form should receive the submission. `replyTo` set to `@` uses the submitted `email` field as the Reply-To address. This lets you reply to the visitor from the notification email. The `honeypot` input catches basic bots that fill every text field. Real visitors never interact with it. See the [honeypot documentation](/docs/forms/security/honeypot) for testing instructions and stronger protection options. ## Step 2: add the thank-you page Create `thank-you.html` beside `contact.html`: ```html Message received

Thanks, your message was sent.

We will reply as soon as we can.

Return to the home page

``` Keep the thank-you page out of your main navigation. A visit to this page can then serve as a clean form-completion event in your analytics tool. ## Step 3: deploy to Cloudflare Pages Cloudflare supports Git integration and direct uploads. Use the one that matches your project. ### Git integration Commit `contact.html` and `thank-you.html` to the connected repository. Cloudflare Pages builds the configured branch and publishes the output automatically. Check your Pages build settings before pushing: - The build command should match your static site generator. Plain HTML sites can leave it blank. - The build output directory must contain both new files. - Preview deployments use a different hostname from production, so test the production deployment before sharing the link. Cloudflare's current [Git integration guide](https://developers.cloudflare.com/pages/get-started/git-integration/) explains the repository and build settings. ### Direct upload If your site is not connected to Git, build it locally and upload the output directory from the Cloudflare Pages dashboard. Do not upload the source directory if your framework generates a separate `dist`, `build`, or `public` folder. See Cloudflare's [Direct Upload guide](https://developers.cloudflare.com/pages/get-started/direct-upload/) for the current dashboard workflow. After deployment, open: ```text https://YOUR-PROJECT.pages.dev/contact.html ``` ## Step 4: test the deployed form Do not stop after checking that the page renders. Run one real submission through the production URL. 1. Open the contact page in a private browser window. 2. Enter a name, an email address you can access, and a recognizable test message. 3. Submit once and confirm that the browser reaches `thank-you.html`. 4. Check the Static Forms inbox for the submission. 5. Check the recipient inbox and spam folder for the notification email. 6. Reply to the notification and confirm that the response is addressed to the email entered in the form. 7. Repeat the test on a phone or narrow browser window. Use a message such as `Cloudflare Pages production test 2026-08-17` so it is easy to find and delete later. ## Lock the form to your Cloudflare domain Anyone can view a form API key in client-side HTML. That is expected. Domain restriction prevents another site from submitting with the same key. In the form's Security settings, add the deployed hostname without `https://` or a path: ```text YOUR-PROJECT.pages.dev ``` If you use a custom domain, add that too: ```text example.com ``` Enable localhost only while you need local testing. Domain restriction is available from the Starter plan up; the [domain restriction guide](/docs/forms/security/domain-restriction) covers subdomains and common configuration mistakes. ## Fix a Content Security Policy error A strict Content Security Policy can block the browser before the request reaches Static Forms. The console usually reports that the form action violates the page's `form-action` directive. Cloudflare Pages reads response-header rules from a plain-text `_headers` file in the static asset directory. If you already send a CSP, add the Static Forms endpoint to `form-action`: ```text /* Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.dev ``` Do not copy that whole policy into an existing site without reviewing it. Merge the `form-action` source into the policy you already use, otherwise you may accidentally block scripts, images, fonts, or API calls needed by the rest of the site. Cloudflare documents the file syntax in [Headers · Cloudflare Pages](https://developers.cloudflare.com/pages/configuration/headers/). ## Common problems | Symptom | Likely cause | What to check | | --- | --- | --- | | The API reports an invalid key | The placeholder was not replaced, or the key belongs to another form | Copy the current key from the Static Forms dashboard | | The form works locally but fails after domain restriction | The production hostname is missing from the allowed list | Add `YOUR-PROJECT.pages.dev` and any custom domain without a protocol | | The submission succeeds but there is no email | The recipient is unverified, the message was filtered, or delivery is delayed | Check the Static Forms inbox first, then spam and recipient settings | | The browser reports a CSP violation | `form-action` does not allow the Static Forms endpoint | Update the Pages `_headers` file and redeploy | | The thank-you page returns 404 | The file was outside the published output directory | Inspect the deployed artifact and verify the exact filename and URL | | You receive bot submissions | A honeypot stops only basic automated traffic | Add reCAPTCHA v2 (any plan), or [Cloudflare Turnstile](/docs/forms/security/turnstile), reCAPTCHA v3, hCaptcha, or ALTCHA (Starter and up), in the form's Security settings | | A custom field is missing | The input has no `name` attribute | Give every submitted input a unique `name` | ## Optional: use Cloudflare Turnstile Cloudflare Turnstile is a useful next step if the honeypot does not stop enough spam. Static Forms supports it from the Starter plan up. Create a Turnstile widget for your production hostname, then configure its secret in Static Forms and add the widget to the page. Follow the [Static Forms Turnstile guide](/docs/forms/security/turnstile). Test both an accepted submission and a failed challenge before enabling domain restriction (Starter plan and up) on every environment. ## Production checklist Before calling the form finished, verify that: - the deployed form uses the correct API key; - the recipient email is verified; - the thank-you URL uses HTTPS and exists in production; - every visible control has a label and a `name`; - keyboard focus is visible; - the honeypot remains empty during a normal submission; - the Pages CSP permits `https://api.staticforms.dev` in `form-action`; - production and custom hostnames are in the domain allowlist; - a real submission appears in both the Static Forms inbox and the recipient inbox. The page remains a static Cloudflare Pages deployment throughout this setup. Static Forms handles form processing, email delivery, submission storage, and optional spam controls without adding a Pages Function to your project. --- ## JavaScript Form Submit to Email: Developer Guide Source: https://www.staticforms.dev/blog/javascript-form-submit-to-email Date: 2026-07-26T07:59:50.583Z Author: Static Forms Team Most advice on **JavaScript form submit to email** skips the part that matters: the browser can collect the data, but it cannot send the email by itself. If you try to make the front end do the whole job, you end up with brittle `mailto:` hacks, exposed credentials, or a form that works in a demo and fails in production. ## Why JavaScript Cannot Send Email Directly The hard constraint is simple, the browser does not provide direct SMTP capability, so **plain client-side JavaScript cannot reliably send email on its own**. That's been true for a long time, and the architecture still matters now, because the form UI and the mail delivery step are separate problems. Guides going back two decades framed the fallback the same way — a `mailto:` action in the form's `ACTION` attribute — and the conclusion hasn't changed: JavaScript alone can't email a form, a backend or hosted service has to do the actual sending. [InformIT's classic write-up on emailing form results](https://www.informit.com/articles/article.aspx?p=30105&seqNum=5) is a good look at how long this constraint has been true. ![An infographic explaining why JavaScript cannot send emails directly due to security, architectural, and browser limitations.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/d8c21d1e-edfd-4277-996d-7aee75f28f3d/javascript-form-submit-to-email-javascript-limitations.jpg) ### The browser handles interaction, not delivery A clean mental model helps here. **JavaScript is for validation, serialization, and user experience**, while the server or email service is for delivery. If you keep those roles separate, the code stays predictable, and the failure modes become obvious. `mailto:` is a weak fallback because it depends on the user's local mail client and can expose the recipient's address in page source. Client-side SMTP libraries look tempting, but they're a security problem because anything in front-end code is visible to users, including credentials and service secrets. That's why static-site developers usually choose a hosted form backend instead of trying to make the browser speak SMTP directly. > **Practical rule:** if the browser is the only thing moving the data, it's not an email pipeline yet. For a deeper dive into the SMTP side of the handoff, this internal guide on [Gmail SMTP server setup patterns](https://www.staticforms.dev/blog/gmail-smtp-server) fits the same architectural boundary. The key point is still the same, JavaScript can start the submission, but something mail-capable has to finish it. ## Building a Working Form That Submits to Email A form that reaches an inbox starts with ordinary HTML, not clever JavaScript. The **action**, **method**, and every input's **name** attribute are the pieces that make the payload serializable. If a field doesn't have `name`, it won't show up in the submitted body, even if it looks fine on screen — the [guide to creating an HTML form](https://www.staticforms.dev/blog/create-an-html-form) covers the markup side in more detail. ![A laptop on a wooden desk displaying JavaScript code for a contact form submission.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/12f3cff0-0416-413f-940e-5d243ec07e91/javascript-form-submit-to-email-coding-workspace.jpg) ### Plain HTML that posts to a hosted endpoint A static-site form can post straight to a mail-capable endpoint. The pattern below uses a realistic submission URL, a hidden API key, and form fields that will serialize correctly. ```html
``` The browser submits those fields as form data, and the endpoint handles the email delivery step. For a JavaScript-enhanced version, the front end can still intercept submit for validation or loading state, then pass the same `FormData` to the endpoint. ### React, Next.js, and Vue patterns In React, the submit handler usually just stops the page reload and forwards the data: ```jsx function ContactForm() { const handleSubmit = async (event) => { event.preventDefault(); const formData = new FormData(event.currentTarget); await fetch('https://api.staticforms.dev/submit', { method: 'POST', body: formData, }); }; return (
``` A good first implementation usually keeps the network path explicit. The endpoint receives the form, processes it, and returns a success or error state you can reflect back in the UI. ## Adding Production Essentials to Your Form Pipeline A form that posts successfully still isn't production-ready. Real sites need **spam protection**, **file handling**, **success and error redirects**, and a sensible email workflow so the inbox doesn't become the only source of truth. The production choice is usually less about “can it send?” and more about “can it survive spam, retries, and real users?” ![An infographic titled Adding Production Essentials to Your Form Pipeline outlining six key steps for form management.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/7105fd27-2951-4384-b13d-e9672c761206/javascript-form-submit-to-email-production-pipeline.jpg) ### Spam protection and file uploads For spam, the common options are **reCAPTCHA v2/v3**, **hCaptcha**, **Cloudflare Turnstile**, **ALTCHA**, and a simple **honeypot** field. A honeypot is easy to ship and has low UX cost, but it's not enough by itself on exposed public forms. CAPTCHA-style checks add friction, so I usually start with the lightest option that meaningfully cuts bot traffic for the site in question. A honeypot is just a field humans never see: ```html ``` On the server or service side, reject submissions when that field isn't empty. Static Forms does this automatically for any field whose name contains `honeypot`, and strips it from the stored submission — see the [honeypot docs](https://www.staticforms.dev/docs/forms/security/honeypot). For reCAPTCHA or Turnstile, verify the token after submit and before any mail is sent. That server-side check matters more than the widget itself. File uploads belong in the same pipeline, but they need more care. Static Forms supports uploads on Starter and up, **up to 4.5MB per submission** counting every file plus the other fields, which is the kind of limit you want surfaced in the UI before the user hits send. If you accept attachments, keep the payload multipart and make sure your endpoint and email flow can carry the file through instead of dropping it. ### Redirects, confirmations, and deliverability Hidden fields are the cleanest way to control redirect behavior after submit: ```html ``` `redirectTo` is the field the submit endpoint reads. Error-page routing is configured per form in the builder rather than posted from the markup, which keeps the form itself simple and avoids baking routing logic into the front end. For confirmation emails, autoresponders help users know their message arrived. If you need richer follow-ups, some teams use AI draft assistance for auto-replies, but the logic still needs to be reviewed before it reaches real customers. Delivery quality also depends on authenticated sending. If the mail comes from your own domain, **SPF, DKIM, and DMARC** belong in the setup conversation from day one. Static Forms documents custom-domain sending, and that's the right direction for production forms because inbox providers care about authentication, not just whether the request completed. A helpful companion read is [email deliverability best practices](https://www.staticforms.dev/blog/email-deliverability-best-practices), especially if your contact form feeds sales or support. ## Comparing Alternative Approaches for Form-to-Email Delivery There isn't one right answer for every site. The trade-off is usually between control, maintenance, and how much delivery infrastructure you want to own. For some projects, a hosted backend is enough. For others, a serverless function or a vendor SDK makes more sense. | Approach | Setup Effort | Spam Protection | Maintenance | Best For | | --- | --- | --- | --- | --- | | Serverless function | Higher | DIY | Higher | Teams that want full control | | EmailJS | Moderate | Service-dependent | Lower | Front-end teams that want client-side initiation | | `mailto:` | Very low | None | Low, but unreliable | Quick internal prototypes | | Hosted form backend | Low | Built-in or configurable | Low | Static sites and JAMstack projects | ### What each option buys you A **serverless function** on AWS Lambda, Vercel, or Netlify gives you control over validation, routing, and custom logic, but you own the moving parts. That's fine when the form has business rules, attachments, or deeper integrations, and it becomes annoying when the form is just a contact box. **EmailJS** sits in a useful middle ground. Its flow uses a public key and a `sendForm(...)` call, so JavaScript can initiate delivery without you running your own backend. The catch is that it still relies on an intermediary service, and the public key sits in your client bundle — worth understanding before you adopt it. `mailto:` is only good as a quick hack. It opens the user's mail client, depends on local configuration, and doesn't give you reliable submission handling. If the goal is actual form processing, it's the wrong tool. For hosted backends, the practical question is whether you want a dashboard, retries, webhooks, and exported submissions without building them yourself. **Static Forms** is one option in that category, with webhooks from its Starter plan up. Whichever you compare, weigh the things that break in production: spam controls, file limits, redirect handling, whether webhooks retry, and how much operational work you're willing to own. ## Security and GDPR Compliance for Static Site Forms Static sites don't get a pass on compliance just because there's no database in your repo. A contact form still collects personal data, and once that data lands in email, a dashboard, or a webhook, you need a clear policy for consent, retention, and deletion. The safest forms make those rules visible in the UI and enforce them on the back end. ### Consent, retention, and user rights For GDPR-sensitive forms, add a required consent checkbox only when you need consent for the purpose being collected. Don't bundle marketing consent with transactional contact intent. Keep the wording separate, and store a timestamp or submission log on the service side so you can show when consent was granted. A minimal checkbox pattern looks like this: ```html ``` If the submission is for newsletter signup, that checkbox should be different from a general contact request. Data export and deletion workflows matter too, which is why a dashboard with search, export, and removal tools saves a lot of manual work when someone asks what you've stored. ### Protect the endpoint, not just the form API keys should never be treated as secret when they're placed in client-side code. If the service expects a public key, restrict what that key can do and validate anything sensitive server-side. The anti-bot token must be checked after submit, not just rendered in the browser. > A form that only works when everything is trusted is a demo, not a production system. Spam bots will hit any visible form that looks useful. If you expose a webhook or email endpoint, rate limiting, server-side validation, and abuse monitoring should sit behind it. That's true whether the front end is vanilla HTML, React, Next.js, or Vue. A final point that gets ignored too often, consent for marketing mail is not the same as consent to reply to a support request. Keep those workflows separate, store only what you need, and don't let a convenient form shortcut turn into a compliance problem later. ## Testing Your Form and Connecting Integrations Before you ship, test the full path, not just the button click. Submit valid data, submit invalid data, check the inbox, confirm the redirect, and inspect the server logs or dashboard record. If the form has attachments, test the boundary cases too, especially when the submission is near the **4.5MB** submission cap supported by Static Forms, which counts every file plus the other fields. ![A five-step flowchart illustrating the process for testing a web form pipeline from submission to logging.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/8b9ad7bb-118b-481e-a9c4-f3af2d8f05ca/javascript-form-submit-to-email-testing-process.jpg) ### A test checklist that catches real failures A good form test run usually includes these checks: - **Valid submission:** send a clean payload and confirm the email arrives with formatting intact. - **Invalid submission:** remove required fields or use a bad email address and verify the front-end and back-end both reject it. - **Spam trigger:** submit with the honeypot filled or the CAPTCHA token missing and confirm the request is blocked. - **Redirect behavior:** confirm both success and error redirects land on the right page. - **Attachment handling:** upload a file near the service's supported limit and verify it still arrives. Those checks matter because the most common failure is an inconsistent handoff between browser state and backend state. If the UI says “sent” before the mail service accepts the payload, users will trust a broken flow. ### Webhooks and downstream tools Once the form is stable, webhooks are the cleanest way to feed other systems. A JSON POST webhook can power Zapier, Make, or n8n, and a good endpoint should retry on failure instead of dropping the event. That same pattern can also append rows to Google Sheets, post to Slack, Discord, or Telegram, create Notion pages, add Airtable records, or subscribe a user to a Mailchimp audience — the [integrations docs](https://www.staticforms.dev/docs/integrations) list what each destination expects. One caveat once automation touches submissions: if a form triggers AI-drafted triage or replies, the privacy story has to stay as clear as the submission flow. Say what the data is used for on the form itself, not only in a policy page nobody opens. If you're exporting CSVs, double-check the field mapping before you build any downstream automations. A clean webhook payload beats manual copy-paste every time, and it keeps the form as the single source of truth for the fields you captured. ## Choosing the Right Form Architecture for Your Project A personal portfolio can usually get by with a hosted backend and basic spam protection. An agency site often wants redirects, webhooks, and a dashboard, while a SaaS landing page may need stronger deliverability, consent controls, and integrations from day one. Enterprise forms usually need the most restraint, because compliance, routing, and retention policies matter as much as the inbox notification. The decision usually comes back to one principle, **JavaScript handles the browser side, and a service handles delivery**. Once you accept that split, the rest of the architecture becomes straightforward. You decide where validation lives, how abuse is blocked, where submissions are stored, and what downstream systems receive the data. For most static and JAMstack projects, I'd start with a hosted form backend, then add webhooks or additional destinations only when the workflow proves it needs them. If the form is tied to regulated data or complex routing, move sooner to a backend you control. The right setup is the one you can operate without babysitting every week. --- If you want to ship a contact form without wiring up your own backend, [Static Forms](https://www.staticforms.dev) gives you an email-backed submission endpoint for static sites, plus a dashboard, redirects, spam controls, and integration paths when you need them. It's a practical fit when you want JavaScript to handle the form experience while a service handles delivery. --- ## EU Data Residency for Submissions and File Uploads Source: https://www.staticforms.dev/blog/eu-data-residency-form-submissions-file-uploads Date: 2026-07-26 Author: Static Forms Team Every form backend makes a quiet decision on your behalf: where the data lands. For most of them the answer is "somewhere in the United States", and you only find out when a customer sends you a DPA questionnaire asking which country their applicants' CVs are stored in. Static Forms now lets you make that decision yourself. Pick **European Union** when you sign up and your form data — submissions, file attachments, webhook delivery logs and AI reply logs — is stored in AWS `eu-central-1` (Frankfurt) instead of `us-east-1`. Not replicated to the US, not cached in the US, not written to a US table first and copied over. The row and the uploaded file are created in Frankfurt and read back from Frankfurt. This post is the engineering detail behind that sentence: how a submission is routed, where an attachment physically sits, how the download link is signed, and — the part most residency announcements skip — the things that deliberately stay in the United States for every account. The exact legal scope is in our [DPA](https://www.staticforms.dev/dpa); this is the same scope, written for developers. --- ## The split: control plane vs data plane The architecture has one rule, and everything else follows from it. **Account state is global. Form data is regional.** | Stays global (US) | Regional (follows your pick) | |---|---| | Your user record, login, billing | Form submissions | | Form/workspace configuration and API keys | File attachments (S3 objects + file records) | | Rate-limit counters, storage quota counters | Webhook delivery logs | | Email delivery (SES) | AI reply logs | | Operational request logs | Submission retention and cleanup | Why keep the control plane global? Because it is what lets there be **one product**. There is no `eu.staticforms.dev`, no second dashboard, no separate login, no parallel release train, and no second API key to manage. You log in at [staticforms.dev](https://www.staticforms.dev), and your API key resolves the same way regardless of region. Only the tables and buckets your submissions live in change. > Your account record — owner email and anonymized consent IP — stays in the US `users` table for every account, including EU ones. We state that plainly in the DPA rather than engineering around it, because pretending otherwise would be the interesting kind of lie. --- ## What happens to a submission There is a single ingest endpoint for everyone: `https://api.staticforms.dev/submit`. You do not swap it for a regional URL, and your HTML does not change. ```html Contact Us
``` Behind that request: 1. **The API key is resolved in the global control plane.** One lookup tells us which account owns the form, what plan it is on, and which region it is pinned to. 2. **Validation runs before anything is stored** — spam checks, CAPTCHA, rate limits, plan limits. Nothing region-specific here. 3. **The submission row is written to the owning account's region.** For an EU account that is the Frankfurt `form_submissions` table. Not a US table with a region flag on it — a different table, in a different AWS region. 4. **Every later write follows the same route.** Delivery status, read/unread, spam and not-spam, timeline entries, webhook attempt logs, AI reply logs. They are all keyed to the region resolved in step 1, including the ones written asynchronously by queue consumers, because the region travels inside the queue payload. The consequence worth internalizing: **an EU account's submission never exists as a row in `us-east-1`.** We verified that the hard way before shipping — after a full end-to-end pass (signup, submission, attachment, inbox, mutations, export, webhook, AI reply, deletion) the US tables held **zero** rows for that account across submissions, file uploads, webhook logs and AI reply logs. --- ## What happens to a file attachment File uploads are the part where residency usually leaks, because a file has two lives: the bytes in object storage, and the metadata row that makes them findable. Both are regional. Adding uploads to the form above is one attribute and one input — again, identical for both regions: ```html
``` What the platform does with `document`: - ✅ **The bytes are uploaded to your region's private bucket.** Each region has its own S3 bucket — bucket names are globally unique, so this is a genuinely separate bucket in a separate AWS region, with Block Public Access on and `AES256` server-side encryption. - ✅ **The file record is written to your region's `file_uploads` table**, alongside the submission it belongs to. The record — original filename, MIME type, size, object key — is data-subject content, so it lives where the submission lives. - ✅ **Downloads are signed against the regional bucket.** Clicking a download in your [Inbox](https://www.staticforms.dev/app/inbox) mints a fresh presigned `GET` URL that expires in **5 minutes**, signed for the bucket the object is actually in. For an EU account the bytes travel from Frankfurt to your browser — there is no US proxy hop and no US copy to serve from. - ✅ **Storage quota accounting stays global.** The counter is a number of bytes on your account record, not form data, so it lives in the control plane with the rest of your account state. The bytes it counts never leave your region. - ✅ **Expiry and deletion happen in region.** Delete a submission and its file records and S3 objects are removed from your region's table and bucket. The retention sweep visits **every** region rather than assuming US, so an EU account's expired attachments are actually collected instead of lingering forever. One deliberate failure mode, since it affects what you see: if persistent storage is unavailable for a file, the submission is still accepted and the file is still delivered by email, but no download button appears for it in the inbox. A storage problem never costs you a lead — and it never quietly falls back to storing the file in another region either. File uploads are available from the Starter plan up, and the whole submission, every file plus the other fields, has to stay under 4.5MB. See [file uploads in the docs](https://www.staticforms.dev/docs/forms/file-uploads) for limits and framework snippets, or the walkthrough in [Next.js contact form with document upload](/blog/nextjs-contact-form-document-upload). For the HTML side (accessible inputs, size checks and previews), see the [HTML file upload guide](/blog/file-upload-html). --- ## Teams that span regions An agency owner pinned to the EU can invite a teammate whose own account is US-region — or who signed up before regions existed at all. Routing that teammate's reads by *their* region would query an empty Frankfurt-less table and render the workspace as if it had no data. So routing follows **the account that owns the data**, never the person looking at it. A US-based teammate opening an EU workspace reads from Frankfurt, and when they mark a submission read, reply, or unmark spam, the write lands in Frankfurt too. Nothing about cross-region team access pulls a copy into the US. --- ## What EU residency does not move This is the section we would rather you read twice than discover later. Selecting the EU region relocates **storage of form data**. It does not relocate everything, and we will not market it as "your data stays in the EU". - **Email delivery is US, for every account.** Notification and auto-responder emails are sent through AWS SES in `us-east-1`, with Singapore (`ap-southeast-1`) as delivery failover. The email carries the submission, so this is a transmission out of the region — disclosed in our [DPA](https://www.staticforms.dev/dpa) §8 and in the [Transfer Impact Assessment](https://www.staticforms.dev/dpa/transfer-impact-assessment), and covered by SCCs / the EU-U.S. Data Privacy Framework. SES does not retain message content, but it does keep a per-region suppression list of recipient addresses. - **Operational request logs are US, for every account.** Request metadata — IP, user agent, referer, delivery status, and form field **names** — is logged in the US for 365 days. **Field values are never logged**, by design and enforced in code: the submit path logs the keys of your payload, never the contents. Keeping this pipeline single-region is what keeps our abuse tooling working; an attacker should not be able to buy log immunity by picking a region at signup. - **Account and billing data are US, for every account** (see the table above). The supportable claim is precise: *form data stored in the EU, email delivered from the US, operational logs in the US.* That is what the DPA, the sub-processor table, Annex III and the TIA all say, and it is what the product actually does. --- ## Choosing and changing your region - **At signup.** The region picker sits above both signup paths — email and Google — and defaults to United States. It applies to the account on its **first** write, so an OAuth signup gets the region you picked, not a US default it has to be rescued from later. - **After signup, while you are empty.** You can change the region from your [account page](https://www.staticforms.dev/app/account) for as long as the account holds **no** stored form data. The check runs server-side across every region — submissions and file uploads, not just the pinned region — and fails closed: if the check itself errors, the change is refused. Refusing a change is recoverable; splitting an inbox across two continents is not. - **Once your first submission arrives, the pin is final.** Not a policy choice, a physical one: the rows and objects are in that region, and there is no migration that keeps a live inbox coherent while it runs. The account page shows the pin as locked with the reason. Pick deliberately, then. If you are collecting personal data from EU residents — job applications, patient intake, member records — the EU region is the one that removes the third-country transfer for that data at rest. --- ## Adding regions from here The region registry is config, not code: a region is an entry with an AWS region, a label, an uploads-bucket environment variable and an email-send region. Every submission, file, log and cleanup path resolves through that registry, so adding a third region (APAC is the obvious next one) means provisioning infrastructure and adding a registry entry — not auditing hundreds of call sites again. If you need residency in a geography we do not serve yet, [tell us which one](https://www.staticforms.dev/contact). That request is now a provisioning ticket rather than a rewrite. --- ## Get started 1. **New account?** Choose your region on the [signup page](https://www.staticforms.dev/register) — United States (default) or European Union (Frankfurt). 2. **Existing account with no submissions yet?** Set it from your [account page](https://www.staticforms.dev/app/account) before your first submission lands. 3. **Need the exact wording for a customer questionnaire?** The [DPA](https://www.staticforms.dev/dpa), the [sub-processor list](https://www.staticforms.dev/dpa/sub-processors) and the [Transfer Impact Assessment](https://www.staticforms.dev/dpa/transfer-impact-assessment) all name the regions, the sub-processors and the transfer mechanisms line by line. 4. **Just getting started with forms?** Begin with [Getting started with Static Forms](/blog/getting-started-with-static-forms), then, on the Starter plan or above, add [privacy-first spam protection with Altcha](/blog/implementing-altcha-captcha-html-website) — no third-party tracking, which pairs well with an EU-resident inbox. That's it. Same endpoint, same API key, same dashboard — your form data just lives where you need it to. --- ## 10 Best Free Form Processing Services for 2026 Source: https://www.staticforms.dev/blog/free-form-processing-service Date: 2026-07-24T09:45:00.596Z Author: Static Forms Team Your static site is live, but the contact form still goes nowhere. You don't need a backend project just to catch leads, route support requests, or let a client upload a file. A **free form processing service** solves that by taking a plain HTML form, posting it to a hosted endpoint, and handling delivery, spam filtering, storage, and sometimes webhooks for you, which is the basic pattern described in the Static Forms guide to [free HTML form processing](https://www.staticforms.dev/blog/free-html-form-processing). ## 1. Static Forms A contact form that just works is usually the difference between a site that looks finished and one that ships. Static Forms fits that job well, because you point the form at `https://api.staticforms.dev/submit`, add an API key, and let the service handle delivery instead of standing up a backend or database. For the broader pattern behind this setup, the [Static Forms HTML form processing guide](https://www.staticforms.dev/blog/free-html-form-processing) lays out the basic flow for static and JAMstack sites. ![Static Forms](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/screenshots/3dd260a5-5238-45d3-904e-fd39120a8fe8/free-form-processing-service-html-form-backend.jpg) What stands out in practice is the handoff. A form backend only earns its place if it gives you a reliable destination for submissions and stays out of the way when the workflow grows. Static Forms supports email delivery, dashboard storage, CSV export, redirects, and paid-plan routing to webhooks (Starter and up) and downstream tools, so it behaves like infrastructure instead of a one-off inbox. > **Practical rule:** start with plain HTML first, then add integrations only when the form earns them. ### Copy-paste HTML ```html
``` ### Copy-paste React ```jsx export default function ContactForm() { return (
``` ### Apps Script `doPost` example ```javascript const SHEET_NAME = 'Responses'; function doPost(e) { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME); const data = e.parameter; sheet.appendRow([ new Date(), data.name || '', data.email || '', data.message || '' ]); return ContentService .createTextOutput(JSON.stringify({ ok: true })) .setMimeType(ContentService.MimeType.JSON); } ``` ### Deployment steps that actually matter 1. **Create the sheet first.** Put your column headers in row one and keep them stable. 2. **Open Extensions > Apps Script.** Paste the script and save it. 3. **Deploy as a web app.** Use the web app deployment flow, authorize it, and copy the URL. 4. **Point the form action at the deployed URL.** Don't use a placeholder in production. 5. **Test submissions from different devices.** If rows don't appear quickly, fix the deployment before launch. > **Practical rule:** treat the sheet like a database table, not like a loose note pad. Once you start mixing headers, manual edits, and ad hoc columns, your form payloads get messy fast. This is also the point where a custom frontend becomes valuable. If you're already using React, Next.js, or Vue, the form can look native to the site while still posting to a Sheet through Apps Script. For a plain HTML foundation, the structure is simple, and if you want a broader front-end walkthrough, this [guide to building an HTML form](https://www.staticforms.dev/blog/create-an-html-form) is a useful reference point. The trade-off is that Apps Script still asks you to own more than people expect. You're responsible for deployment, permissions, field mapping, and any future schema changes. If your form is going to evolve often, that maintenance can become the hidden cost. ## Method 3 Programmatic Control with the Google Sheets API When the form submit path already runs through your own backend or serverless function, the Google Sheets API is the most direct option. It's more work upfront, but it gives you explicit control over authentication, data shaping, retries, and validation before anything reaches the spreadsheet. The surface you'll be calling is `spreadsheets.values.append`, documented in the [Sheets API reference](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append). ### What the setup looks like The usual flow is to create a Google Cloud project, enable the Sheets API, and generate a service account with JSON credentials. Then you share the target spreadsheet with that service account's email address so it can write rows as an editor. From there, a backend function can authenticate and append rows programmatically. That setup is better than Apps Script when the form lives in a broader system. You can validate input, enrich it, and decide what gets written to Sheets at all. It also solves a recurring gap in the simpler workflows: attribution metadata. Native [Google Forms](https://support.google.com/docs/answer/6281888) can only carry campaign or referrer data through prefilled link tricks, whereas a programmatic handler can read `document.referrer` and UTM parameters from your own page and write them as real columns. ### Node.js example for appending a row ```javascript import { google } from 'googleapis'; const auth = new google.auth.GoogleAuth({ credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON), scopes: ['https://www.googleapis.com/auth/spreadsheets'] }); async function appendFormRow({ name, email, message, source }) { const client = await auth.getClient(); const sheets = google.sheets({ version: 'v4', auth: client }); await sheets.spreadsheets.values.append({ spreadsheetId: process.env.SHEET_ID, range: 'Responses!A:D', valueInputOption: 'RAW', requestBody: { values: [[new Date().toISOString(), name, email, message, source]] } }); } ``` ### When this is the right call Use the API when the spreadsheet is one destination among several. A single submission might need validation, tagging, deduplication, and then writes to Sheets plus another system. The Sheets API fits that model better than trying to force everything through a form endpoint. It's also the cleanest choice when your team already has a backend service. In that case, Sheets is just another integration target, and the form is only the first step in a broader workflow. The cost is complexity, not just in setup but in long-term maintenance of credentials and request handling. For a more opinionated implementation path, the [Static Forms Google Sheets integration docs](https://www.staticforms.dev/docs/integrations/google-sheets) (a Pro plan feature) are useful if you're comparing backend-first patterns with managed form endpoints — same destination, no service-account credentials to rotate. ## Method 4 No-Code Automation with Zapier or Make ![A hand using a laptop to create an automated workflow connecting an HTML form to Google Sheets.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/f7b08962-884b-4cb9-8410-68f28b171642/form-google-sheets-automation-workflow.jpg) Zapier and Make sit between code and pure no-code. You keep a custom HTML form, while the backend flow stays visual. The form posts to a webhook URL, the automation tool receives the payload, and a spreadsheet action writes the row into Google Sheets. That setup fits teams that want a fast path to a working form without deploying Apps Script or managing API credentials directly. The trade-off is control. You gain speed and a clearer setup path, but you also accept another service in the middle of the submission flow. For small lead forms, that is usually fine. For stricter systems, the extra hop can matter. Google Sheets becomes more useful once the data lands there. Pivot tables with date grouping let you summarize submission timestamps by day, so the sheet works as an operational reporting layer instead of a dead-end inbox. ### How the workflow is usually wired - **Create the webhook trigger:** Build a Zap or Scenario that listens for POST requests at a unique URL. - **Point the form action there:** Your HTML form posts directly to that webhook endpoint. - **Map fields to columns:** Match names like `name`, `email`, and `message` to the target sheet. - **Add follow-up steps if needed:** Slack alerts, CRM updates, and email notifications can happen after the row is created. That flexibility is why teams pick these tools. They can do more than write a row, and they are easier to adjust than custom code when a client asks for one extra step in the workflow. ### Where the compromise shows up Cost and latency are the main downsides. You depend on a third-party workflow engine, so the result is not as immediate or deterministic as posting straight to the Sheets API. For many lead forms, that is acceptable. For a time-sensitive internal workflow, it can be a poor fit. A managed integration can be simpler than spreading the logic across separate tools. Static Forms, for example, can send submissions to Google Sheets as one of its destinations on Pro and up, which matters if you want a managed endpoint rather than a custom webhook stack. If you are already using Zapier, the [Zapier integration overview](https://www.staticforms.dev/integrations/zapier) helps compare how those pieces fit together. ## Comparing Methods and Handling Advanced Features The cleanest way to choose is by control, maintenance, and how much of the workflow you want to own. Native Google Forms is the quickest path, but it gives you the least front-end control. Apps Script gives you a free serverless bridge with enough flexibility for many static sites. The Sheets API gives you full programmatic control, which matters when the form is only one part of a larger system. Zapier or Make reduce coding, but they add a recurring dependency and another place for failures. Managed endpoints like Static Forms sit in the middle when you want one place to handle submission routing, Google Sheets delivery (Pro and up on Static Forms), and other integrations without building the plumbing yourself. | Method | Setup Complexity | Cost | Frontend Control | Key Feature | | --- | --- | --- | --- | --- | | Native Google Forms | Low | Low | Low | Fastest path to a linked sheet | | Google Apps Script | Medium | Low | High | Custom HTML form with a script backend | | No-Code Automation | Medium | Recurring | High | Visual workflow with extra actions | | Google Sheets API | High | Low to variable | High | Full programmatic control | | Managed form backend | Low | Low to recurring | High | One endpoint, Sheets plus other destinations | The production problems usually show up after the first row lands in Sheets. Reliability, duplicate handling, and column consistency matter once the sheet starts acting like a lightweight CRM or lead pipeline. A lot of setup guides stop at append logic and leave out the parts that keep data usable over time: what happens when a submission arrives while someone has the sheet open in edit mode, what happens when a column gets reordered, and what happens when the write fails and nobody retries it. ### Advanced features that change the architecture **Spam protection** should be planned before launch, not bolted on later. An Apps Script `doPost` endpoint or a raw webhook URL has none, so a scraped form action becomes a spam firehose straight into your spreadsheet. reCAPTCHA and Turnstile are the common challenge options, and [honeypot fields](https://www.staticforms.dev/docs/forms/security/honeypot) work well when you want a low-friction signal without forcing every visitor through a challenge. **File uploads** should usually not go straight into the sheet. A safer pattern is to upload the file to Google Drive or S3 first, then store the file URL in Sheets. That keeps the spreadsheet from turning into file storage and makes the workflow easier to audit. **Privacy and compliance** matter too. If you're collecting personal data, you need to handle consent, retention, export, and deletion. A form backend should support those needs without turning them into a spreadsheet cleanup job. ### Practical trade-offs by use case - **Simple contact form:** Native Google Forms works if branding does not matter. - **Custom marketing site:** Apps Script or a managed backend fits better because you control the markup. - **Lead routing and enrichment:** The Sheets API or automation tools are better when submissions need to branch. - **Client handoff:** Managed endpoints reduce maintenance when a site owner does not want to touch code. For teams that want a single endpoint with spam filtering, file uploads up to **4.5MB per submission** (every file plus the other fields), Google Sheets delivery, and other routing options, a managed form backend is often easier to maintain than stitching together scripts and webhooks. On Static Forms, uploads need Starter or above and Google Sheets delivery needs Pro or above. That helps when the same form has to send data to multiple destinations without changing the frontend each time. > **Practical rule:** if your form needs uploads, attribution, and retry behavior, stop thinking of it as “just a form” and start treating it like a small ingestion system. If you want to move faster on a static site without building and maintaining the full submission stack yourself, [Static Forms](https://www.staticforms.dev) posts plain HTML to a single endpoint and emails each submission; paid plans also forward it to Slack or a webhook (Starter and up) or Google Sheets (Pro and up). The [quick start](https://www.staticforms.dev/docs/quick-start) is one `action` attribute and a hidden API key — no Apps Script deployment, no service account, no spreadsheet permissions to babysit. --- ## Web3Forms vs Static Forms (2026): Limits, Prices, Trade-offs Source: https://www.staticforms.dev/blog/web3forms-vs-static-forms-comparison Date: 2026-07-05 Author: Static Forms Team Web3Forms and Static Forms do the same job. You point an HTML form at an endpoint, add a key, and submissions arrive in your inbox without a server of your own. Both free plans give you 250 submissions: a month on Web3Forms, every 30 days on Static Forms. The real differences are how many forms you get for free, what the first paid plan includes, and what happens to your data afterwards. We make Static Forms, so read this as our side of the comparison. Web3Forms plan limits and features below come from its pricing page (web3forms.com/pricing), checked on 26 October 2026; the security and privacy answers come from its documentation FAQ (docs.web3forms.com). We don't quote Web3Forms prices, because its pricing page and its llms.txt summary listed different prices that day. Check web3forms.com/pricing for the current figure. Static Forms prices are in US dollars. > **Looking for a Web3Forms alternative instead?** The [Web3Forms alternative page](/alternatives/web3forms) is the short version, with the plans side by side. This post is the detailed head-to-head. ## Quick Comparison Overview Checked 26 October 2026. Web3Forms facts come from web3forms.com/pricing; Static Forms facts from our [pricing page](/pricing) and docs. | | Web3Forms | Static Forms | |---|---|---| | **Free submissions** | 250/month | 250 every 30 days | | **Forms on Free** | Unlimited forms and domains | 1 form | | **Cheapest paid plan** | Starter, yearly only, by application (students, early startups, non-profits): 5,000/month | Starter, $6/month or $48/year: 3,000/month, unlimited forms | | **Cheapest plan open to anyone** | Pro: 10,000/month | Starter, $6/month or $48/year: 3,000/month | | **Next plan up** | Agency: 20,000/month | Pro, $9/month or $90/year: 25,000/month | | **File uploads** | Pro and above (5 GB on Pro) | Starter and up (1 GB on Starter, 5 GB on Pro) | | **Autoresponder** | Pro and above | Starter and up | | **Spam protection on Free** | hCaptcha and honeypot | reCAPTCHA v2, honeypot and spam filter | | **reCAPTCHA v3 and Turnstile** | Paid plans only | Starter and up (with hCaptcha and ALTCHA) | | **Webhooks** | Paid plans only | Starter and up | | **Submission history** | 30 days Free, 3 months Starter, 1 year Pro and Agency | Last 30 days visible on Free, unlimited on paid plans | | **At the plan limit** | Stops accepting submissions until the next month | Free and Starter stop accepting; Pro and Agency can opt in to overage at $1 per 1,000 | ![Table comparing Web3Forms and Static Forms: both free plans allow 250 submissions, per month on Web3Forms and every 30 days on Static Forms, Web3Forms allows unlimited free forms against one on Static Forms, and file uploads start on Web3Forms Pro and Static Forms Starter](/blog/web3forms-vs-static-forms-comparison-infographic.png) ## Pick Web3Forms if, pick Static Forms if **Pick Web3Forms if:** - You run several small sites and want each one on a free plan. Web3Forms Free allows unlimited forms and domains; Static Forms Free allows one form. - You qualify for its Starter plan. It allows 5,000 submissions a month, more than our Starter's 3,000, as long as you don't need file uploads or autoresponders. - You want hCaptcha on a free plan. On Static Forms, hCaptcha is Starter and up. **Pick Static Forms if:** - You need file uploads or autoresponders and don't want to move up to Web3Forms Pro. Both start on our Starter plan ($48/year). - You send more than 20,000 submissions a month. Web3Forms' largest listed plan, Agency, stops at 20,000; our Pro covers 25,000 for $90/year and our Agency 30,000. - You want submissions kept in a searchable inbox for as long as you're on a paid plan, not 3 months or a year. - You want a CAPTCHA that doesn't call a third party. [ALTCHA](/blog/implementing-altcha-captcha-html-website) runs without one (Starter and up; no tracking, no cookies). ## When Web3Forms is the better choice Switching only makes sense if you're running into one of the limits above. Stay where you are if: - **Your free setup already works.** If a handful of sites send fewer than 250 submissions a month between them, Web3Forms Free covers you, and Static Forms Free (250 every 30 days) would cover only one of those forms. - **You're on the Starter discount.** Its Starter gives 5,000 submissions a month at a discounted yearly price. Moving to us would get you 3,000 a month on Starter, or 25,000 on Pro for $90/year. - **You only need email.** If nobody on your team needs uploads, an autoresponder or an integration, the paid features we'd point to don't matter to you. - **Changing forms has a cost.** Every form you move needs its action URL and key changed and a test submission. On a large client portfolio, that's real work for no gain if the current setup isn't hurting. ## Pricing Breakdown ### Web3Forms plans (from web3forms.com/pricing, 26 Oct 2026) We've left out Web3Forms prices: its pricing page and its llms.txt listed different figures that day. Check web3forms.com/pricing for the current price. - **Free:** 250 submissions a month, unlimited forms and domains, hCaptcha, honeypot, custom redirect, 30-day submission history. - **Starter (yearly only, by application):** for students, early startups and non-profits. 5,000 submissions a month, 3-month history. No file uploads and no autoresponders. - **Pro:** 10,000 submissions a month, file uploads with 5 GB of storage, autoresponders, the Submissions API, up to 5 active integrations, 1-year history, 3 team seats. - **Agency:** 20,000 submissions a month, 10 GB of storage, 10 seats, 20 linked email addresses, dedicated support. ### Static Forms pricing - **Free, $0:** 250 submissions every 30 days, 1 form, reCAPTCHA v2, honeypot, spam filter. The Inbox shows the last 30 days. - **Starter plan, $6/month or $48/year:** 3,000 submissions a month, unlimited forms, file uploads (1 GB stored), webhooks, Slack, Telegram and Discord, autoresponders, AI Reply, every CAPTCHA option, domain restriction. Stops at 3,000 a month; no overage charges. - **Pro plan, $9/month or $90/year:** 25,000 submissions a month, 5 GB of storage, Google Sheets, Notion, Airtable and Mailchimp, form rules, a custom sending domain, CC recipients, branding removal, up to 3 team members. Overage is optional and off by default: $1 per 1,000 extra submissions. - **Agency, $19/month or $192/year:** 30,000 submissions a month, 10 GB of storage, unlimited team members, same opt-in overage. Full details are on the [pricing page](/pricing). ### Which plan covers your volume Plan limits as listed on each product's pricing page on 26 October 2026. Static Forms prices are yearly; for Web3Forms prices, see its pricing page. | Submissions per month | Web3Forms plan | Static Forms plan | |---|---|---| | Up to 250 | Free | Free (1 form, 250 every 30 days) | | 251 to 3,000 | Starter (if eligible), otherwise Pro | Starter, $48/year | | 3,001 to 5,000 | Starter (if eligible), otherwise Pro | Pro, $90/year | | 5,001 to 10,000 | Pro | Pro, $90/year | | 10,001 to 20,000 | Agency | Pro, $90/year | | 20,001 to 25,000 | Above Agency; not listed | Pro, $90/year | | 25,001 to 30,000 | Above Agency; not listed | Agency, $192/year | If you need uploads or autoresponders, ignore the Web3Forms Starter rows: those features start on its Pro plan. ## Setup and Implementation Both use the same model: a public key in a hidden field, posted to one endpoint. ### Web3Forms ```html Contact
``` ### Static Forms Create a free account, copy your `apiKey` from the dashboard, and post to `https://api.staticforms.dev/submit`: ```html Contact
``` To submit with JavaScript instead of a page load, send JSON: ```html Contact

``` Every parameter is listed in the [API reference](/docs/api-reference). If you'd rather not write markup, the [HTML form generator](/tools/form-generator) builds a form in plain HTML, React or Next.js. ## Is Web3Forms safe? Web3Forms' FAQ answers the two questions you're probably asking. **Is the public access key a risk?** Web3Forms says the key is public, stores no sensitive data, and that someone who copies it "can only send you emails". The practical risk is spam. Its FAQ points to CAPTCHAs and its domain restriction, which is a paid feature. Static Forms works the same way: the `apiKey` sits in your HTML. Domain restriction is Starter and up, and reCAPTCHA v2 and the honeypot work on Free. **Where does the data go?** The FAQ says its servers are in the US-East region. On GDPR, it says the company is confident it complies but "cannot make any legal statements". ## Data retention: what Web3Forms says, and where it disagrees with itself This is the part of the comparison that took the longest to pin down, because Web3Forms' pages don't agree with each other: - Its **pricing page** lists submission *history* by plan: 30 days on Free, 3 months on Starter, 1 year on Pro and Agency. - The same page lists a *data retention policy* of up to 3 years on Free and Starter, and unlimited on Pro and Agency. - Its **documentation FAQ** says: "We do not store any form submissions of our users." We read the FAQ line as out of date, but that's our reading, not theirs. If retention matters for compliance, ask Web3Forms which statement applies to your plan. Static Forms separates the two ideas in the product. *History* is how far back the Inbox shows: the last 30 days on Free, everything on paid plans. *Retention* is how long a submission is stored: up to one year on Free, and unlimited by default on paid plans, with an optional shorter period per form (from 30 days to 5 years). ## Spam Protection **Web3Forms:** Free includes hCaptcha and a honeypot. reCAPTCHA v3 and Cloudflare Turnstile are on paid plans only. **Static Forms:** every plan gets the honeypot, reCAPTCHA v2 and a [spam filter](/docs/forms/spam-filter) that moves flagged submissions to a Spam tab instead of deleting them. Each form chooses the AI filter or a classic reputation filter. Turnstile, hCaptcha, ALTCHA and reCAPTCHA v3 are Starter and up. The [security docs](/docs/forms/security) cover each option. ```html Contact
``` ## Feature Comparison ### File Uploads Web3Forms uploads start on Pro, with 5 GB of file storage (10 GB on Agency). Static Forms uploads are Starter and up. The limit is 4.5 MB per submission in total, across all files and fields, as the [file upload docs](/docs/forms/file-uploads) explain. ```html Send a file
``` ### Webhooks and Integrations Web3Forms lists Google Sheets, Slack, Discord, Telegram and webhooks, on paid plans only; the number of active integrations rises with the plan. Static Forms webhooks are Starter and up, along with Slack, Telegram and Discord. Google Sheets is Pro and up, and so are Notion, Airtable and Mailchimp. Zapier, Make and n8n are Pro and up. ### Notifications and Replies Both send each submission to your inbox. Web3Forms autoresponders start on Pro; CC and multiple recipients are on paid plans only. On Static Forms, autoresponders are Starter and up, CC recipients are Pro and up, and a custom sending domain lets you send from your own domain (Pro and up). [AI Reply](/docs/forms/ai-reply) drafts a reply from a knowledge base you upload and runs on prepaid credits (Starter and up). Web3Forms' pages don't list an AI reply feature. ### Redirects Web3Forms uses a `redirect` field. Static Forms uses `redirectTo`. ## Migrating From Web3Forms to Static Forms If you're already on Web3Forms, your HTML structure stays the same: **1. Update the form action:** ```html
``` **2. Swap the key field:** ```html ``` **3. Rename fields if needed:** `redirect` becomes `redirectTo`. **4. Check spam protection:** rename Web3Forms' `botcheck` honeypot field to `honeypot`. hCaptcha needs Static Forms Starter or above; on Free, use reCAPTCHA v2. ## Which Should You Choose? The two lists near the top have the detail. In short, Web3Forms is the better fit for many small free forms, or for 5,000 email-only submissions a month on its discounted Starter. Static Forms is the better fit when you need uploads or autoresponders on the $48/year Starter plan, more than 20,000 submissions a month, or submission history that doesn't expire on a paid plan. To try it, [create a free Static Forms account](https://www.staticforms.dev) and send a test submission to your one free form. The [pricing page](/pricing) has every plan limit. Comparing services with similar names? [Static Forms vs StaticForm](/alternatives/staticform) covers StaticForm (staticform.app), a separate form backend that's easy to confuse with us. More guides: - [HTML form to email without a server](/blog/html-form-send-email-without-server) - [React contact form tutorial](/blog/react-contact-form-email-tutorial) - [Next.js tutorial](/blog/using-staticforms-with-nextjs) --- ## Create WordPress Form Without Plugin: Native PHP & External Source: https://www.staticforms.dev/blog/wordpress-form-without-plugin Date: 2026-06-26T09:59:13.753Z Author: Static Forms Team You need a form on a WordPress site, but you don't want another plugin adding its own UI, CSS, database tables, and update cycle. That's a reasonable instinct. A **WordPress form without plugin** is possible, but it usually means choosing between two very different architectures instead of avoiding installation. One path keeps everything inside WordPress with PHP and core hooks. The other sends submissions to an external form endpoint, which is closer to how JAMstack teams already handle forms on static sites. Both work. The wrong choice is assuming “no plugin” means “just paste HTML and you're done.” ## Why Build a WordPress Form Without a Plugin Most developers who search for a WordPress form without plugin are trying to avoid one of three things. Extra plugin overhead, plugin lock-in, or a visual builder that fights the rest of the stack. There's also a real privacy and architecture concern behind the search. Many guides skip the native implementation entirely and jump straight to embedded third-party builders. That leaves a gap for developers who want a **self-contained HTML form with server-side handling**, especially when data ownership matters. ### The two real no-plugin options The practical choices are: - **Native WordPress PHP handling:** Your form posts to WordPress, usually through `admin-post.php`, and your own PHP callback sanitizes, validates, stores, emails, and redirects. - **External endpoint handling:** Your form still lives in WordPress, but submissions go to a hosted backend endpoint instead of your theme or plugin code. Those options solve different problems. The native route gives you full control over every field, every handler, and every side effect. It also gives you responsibility for validation, spam controls, mail delivery, redirects, storage, and long-term maintenance. The external endpoint route keeps your frontend simple. If you're already comfortable with HTML, React, Next.js, or Vue, it feels natural because the form becomes just another POST request. > **Practical rule:** “Without a plugin” doesn't mean “without backend logic.” It means you're choosing where that logic lives. If you're still deciding whether avoiding plugins is worth the effort, it helps to compare plugin-based tradeoffs too. This roundup of the [best contact form options for WordPress](https://www.staticforms.dev/blog/best-contact-form-for-wordpress) is useful because it frames the decision from a maintenance angle rather than pretending one pattern fits every project. ## Method 1 The Native PHP Way Using admin-post.php The native approach is the closest thing to a “pure” WordPress form without plugin. It's also the one people underestimate most. You're not just writing HTML. You're wiring a submission flow into WordPress itself. Creating a WordPress form without a plugin via the native method requires using `admin-post.php` as the endpoint, registering custom action hooks like `admin_post_nopriv_contact_form`, and writing a handler function to sanitize data and interact with the database using the `$wpdb` class. As described in [this walkthrough of native WordPress form submission](https://dev.to/readymadecode/submit-a-form-in-wordpress-without-plugin-33mb), it's a **multi-file PHP process** and not a simple HTML task. ![A step-by-step infographic illustrating how to handle WordPress forms natively using admin-post.php for custom development.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/c2f42712-052f-4b6b-9179-0716b2346f20/wordpress-form-without-plugin-form-handling.jpg) ### Build the page template Create a custom page template in your theme, for example `page-contact-native.php`. ```php

Contact

Thanks. Your message has been sent.

There was a problem with your submission.

``` This form does four important things: 1. Posts to `admin-post.php` 2. Sets an `action` value WordPress can route 3. Includes a nonce for CSRF protection 4. Adds a honeypot field for basic bot filtering ### Add the handler in functions.php Now wire the submission handler into WordPress. ```php ', 'Content-Type: text/plain; charset=UTF-8', ]; wp_mail($admin_email, $subject, $body, $headers); wp_redirect(home_url('/contact/?status=success')); exit; } ``` That's enough for a working contact form. It handles logged-in and logged-out users, sanitizes fields, validates required input, sends mail with `wp_mail()`, and redirects on completion. > Keep the form handler small. Once validation, spam checks, file uploads, persistence, and integrations all land in one callback, `functions.php` turns into an unreviewable mess. ### Store submissions in a custom table If email isn't enough, add database persistence. The native pattern commonly uses `$wpdb` with a custom table such as `(table_prefix)custom_contact_form`. ```php global $wpdb; $table_name = $wpdb->prefix . 'custom_contact_form'; $wpdb->insert( $table_name, [ 'name' => $name, 'email' => $email, 'website' => $website, 'message' => $message, 'submitted_at' => current_time('mysql'), ], [ '%s', '%s', '%s', '%s', '%s', ] ); ``` The “no plugin” story reveals its true complexity. You'll also need table creation, schema updates, export logic, retention rules, admin views if you want a dashboard, and cleanup if the theme changes. ### When the native method is the right fit Use it when these conditions are true: - **You want no external dependency:** Everything stays inside WordPress and your hosting environment. - **You're comfortable in PHP:** Not just template editing, but request handling and sanitization. - **Your form rules are custom:** Conditional business logic, custom post creation, internal workflows, or nonstandard persistence. If you're a frontend-heavy team, this approach often feels heavier than expected. It works best when WordPress is already the application runtime, not just the CMS. ## Method 2 The Modern Way Using an External Endpoint The other no-plugin pattern is simpler to reason about. Keep the form markup in WordPress, but post submissions to a dedicated form backend endpoint instead of processing them through WordPress PHP. For developers used to static sites, this is the familiar model. HTML submits to an API. React, Next.js, and Vue call the same endpoint with `fetch`. WordPress becomes the place where the form is rendered, not the place where submission logic lives. ![Screenshot from https://www.staticforms.dev](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/screenshots/9e9d2257-792c-4d14-8603-1658443aea54/wordpress-form-without-plugin-static-forms.jpg) ### Plain HTML example You can place this in a Custom HTML block or a theme template: ```html

``` This removes all WordPress-side handling. No `admin-post.php`, no hooks, no custom table, no `wp_mail()`. ### React and Next.js example ```jsx import { useState } from "react"; export default function ContactForm() { const [status, setStatus] = useState("idle"); async function handleSubmit(e) { e.preventDefault(); setStatus("submitting"); const formData = new FormData(e.currentTarget); const response = await fetch("https://api.staticforms.dev/submit", { method: "POST", body: formData, }); if (response.ok) { setStatus("success"); e.currentTarget.reset(); } else { setStatus("error"); } } return (

Thanks. Your message was sent.

Something went wrong.

``` For teams that need to route submissions into other systems, webhook support matters more than WordPress integration. This overview of [form webhook integrations](https://www.staticforms.dev/integrations/webhooks) shows the shape of that pattern well: form in the frontend, backend endpoint receives the submission, then forwards JSON to whatever automation stack you already use. ## Comparing the Native vs External Endpoint Approaches The decision usually comes down to who owns backend complexity. If you choose native PHP, your code owns it. If you choose an external endpoint, the service owns more of it. Developers often default back to plugins like Contact Form 7 because hand-rolled HTML and PHP requires custom validation, security work, and operational features such as exports, all of which must be built manually. That trade-off is described clearly in [this developer discussion about simple free WordPress forms](https://www.reddit.com/r/Wordpress/comments/q3v5ak/free_way_to_create_a_very_simple_contact_form/). ### Native PHP vs. External Endpoint Form Handling | Criterion | Native PHP (admin-post.php) | External Endpoint (e.g., Static Forms) | |---|---|---| | Setup complexity | Higher. You write templates, hooks, handlers, and redirects | Lower. Point the form at an endpoint and submit | | Runtime dependency | WordPress and your theme code | Third-party service plus frontend form | | Control | Maximum control over request flow and storage | Less low-level control, more configuration-driven | | Maintenance | You maintain validation, mail flow, storage, and updates | The service handles more backend behavior | | Security burden | Mostly yours | Shared, but still requires safe frontend implementation | | JAMstack fit | Awkward for frontend-heavy teams | Natural fit for static and decoupled builds | | Integrations | Custom code required | Often available via webhooks or built-in connectors | | Portability | Coupled to WordPress | Easier to reuse across WordPress and non-WordPress projects | ### What usually works best Native PHP works well when the form is part of a larger WordPress application. Think internal tools, custom post creation, or logic that already depends on WordPress users, capabilities, or post data. External endpoints work well when WordPress is mostly a content layer and your team thinks in components, APIs, and hosted infrastructure. > If your first instinct is to ask where CSV export, spam filtering, redirect logic, and webhook retries live, you're already thinking like someone who should compare maintenance burden before writing code. ### A blunt decision rule Pick the native route if you want **full ownership** and are willing to carry the operational work. Pick the external route if you want **frontend simplicity** and you're fine depending on a service for submission handling. ## Securing Forms and Ensuring Email Delivery A form that accepts submissions but lets bots through or drops mail into spam folders isn't finished. Most custom implementations break down at this point, especially when developers stop at “the POST request works.” ![An infographic checklist for essential WordPress form security and improved email deliverability using best practices.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/c9e7c2d1-9bec-47be-8ce2-be224747cc52/wordpress-form-without-plugin-form-security.jpg) ### Stop spam before it becomes an inbox problem Start with simple controls, then add stronger checks where the form attracts abuse. - **Use a honeypot field:** A hidden field catches simple bots that fill every input. - **Verify on the server:** Client-side required fields help UX, but bots ignore them. - **Add a challenge when needed:** reCAPTCHA v2, reCAPTCHA v3, or Cloudflare Turnstile are common choices. - **Check nonce values in WordPress:** If you stay native, nonce verification is not optional. A minimal WordPress nonce check looks like this: ```php if ( !isset($_POST['contact_form_nonce']) || !wp_verify_nonce($_POST['contact_form_nonce'], 'contact_form_nonce_action') ) { wp_die('Invalid form submission.'); } ``` And a basic honeypot in HTML looks like this: ```html

``` > **Field note:** Spam prevention works best in layers. Honeypot for low-effort bots, server-side validation for all requests, and CAPTCHA only when the form actually needs it. ### Handle file uploads carefully Uploads change the threat model. They also change the form markup. To enable file uploads, the form must include `enctype="multipart/form-data"`, and hosted form environments commonly enforce a size limit, such as Static Forms' **4.5MB** for the whole submission (every file plus the other fields; uploads need its Starter plan or above), as documented in [this file upload guidance](https://www.staticforms.dev/docs/forms/file-uploads). Client-side JavaScript checks are recommended to prevent failed requests before the user submits. That requirement applies even if your product copy quotes a per-file ceiling. In practice, validate on the client and enforce the server or service limit you operate under. ```html
``` Client-side validation: ```html ``` Also validate file type server-side. Never trust the browser's `accept` attribute as a security control. ### Improve email delivery A lot of custom WordPress forms “work” but still fail operationally because `wp_mail()` alone doesn't guarantee inbox placement. If your site sends messages from your domain, the domain's mail authentication matters. Use a sending setup that aligns with your domain and make sure **SPF, DKIM, and DMARC** are configured correctly. If the form is native, route mail through authenticated SMTP or a transactional email provider instead of relying purely on default server mail behavior. This is also where the `From` address matters. Sending from an address on your own authenticated domain is safer than pretending the submitter's address is the sender. Use `Reply-To` for the user's email instead. For a practical deliverability checklist, this guide to [email deliverability best practices](https://www.staticforms.dev/blog/email-deliverability-best-practices) covers the operational side well. ### Cover GDPR and data handling If the form collects personal data, document what you collect, why you collect it, and how long you keep it. The implementation details vary, but the principles stay the same: - **Add consent text where needed:** Especially for marketing or follow-up beyond the immediate request. - **Minimize collection:** Don't ask for fields you won't use. - **Support deletion and export:** If you store submissions, you need a way to remove or provide them. - **Be careful with webhooks:** Once a submission fans out to Slack, Sheets, Notion, or a CRM, your data map gets wider. A form is part UI, part backend, and part compliance surface. Developers usually get the UI right first and discover the other two later. ## Conclusion Which Path Is Right for You The best WordPress form without plugin depends less on ideology and more on what you're maintaining six months from now. If you want total control, already work comfortably in PHP, and need WordPress-aware business logic, the native `admin-post.php` route is a solid fit. It keeps everything in your stack, but it also makes you responsible for validation, storage, spam filtering, delivery, and future changes. If you care more about speed of implementation, frontend ergonomics, and decoupling submission handling from WordPress, an external endpoint is usually the more practical choice. That's especially true for teams already building across WordPress, static sites, and JavaScript frameworks. The mistake isn't choosing one over the other. The mistake is treating forms like a small feature. They touch security, email infrastructure, privacy, storage, and support workflows. Pick the version you can maintain well, not just the one you can ship fast. --- If the external-endpoint approach matches how your team already builds, [Static Forms](https://www.staticforms.dev) is a straightforward way to add a production-ready form backend to WordPress, React, Next.js, Vue, and static sites without maintaining your own form processing code. --- ## Mastering Form Encryption Beyond TLS in 2026 Source: https://www.staticforms.dev/blog/form-encryption Date: 2026-06-20T09:46:24.270Z Author: Static Forms Team You've got a contact or intake form on a static site, HTTPS is green in the browser, and someone on the team says, “We're encrypted.” That's only partly true. TLS protects the trip from the browser to your endpoint. It doesn't answer the harder questions: who can read submissions after they land, what ends up in logs or exports, how integrations handle sensitive fields, and what happens when the one person holding the decryption key leaves. ## Your First Line of Defense and Its Limits HTTPS is the baseline. If your form posts over plain HTTP, fix that first and don't debate anything else until it's done. But **TLS only covers data in transit**. It protects the connection between the browser and the server. Once the server receives the payload, the protection model changes completely. Your app, your database, your backups, your admin dashboard, and any integration that touches the submission can all become exposure points. For frontend teams, that distinction matters because “secure form” can mean very different things: - **Transport security:** The browser sends data over TLS. - **Storage security:** The backend encrypts stored submissions. - **Access security:** Only authorized people can view submissions. - **End-to-end confidentiality:** Data is encrypted before it leaves the browser, so the backend can store it without being able to read it. If you're collecting basic contact messages, TLS plus sane backend controls may be enough. If the form collects identity documents, tax details, health information, API keys, or anything users would reasonably expect staff not to casually browse, you need a stricter model. A good rule is simple. Ask where the plaintext exists after submit. If the answer includes your app server, logs, support inbox, webhook payloads, CSV exports, or third-party integrations, then TLS isn't the whole story. > **Practical rule:** Treat form encryption as a data lifecycle problem, not a checkbox on the transport layer. If you want a concise baseline for form transport and submission security on static sites, [Static Forms' security documentation](https://www.staticforms.dev/docs/forms/security) is a useful reference for the minimum controls you should expect from a hosted form backend. ## Understanding Form Encryption Threat Models When developers say “the form is secure,” they often mean the POST request is protected on the wire. That's the armored truck model. TLS keeps other people from reading the payload while it moves across the network. The truck arrives, unloads the data, and leaves. After that, different threats matter. ![A diagram illustrating stages of form encryption, potential threat models, and security risks during data transit.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/8e875aea-1361-44f4-8087-61c2348898fa/form-encryption-threat-models.jpg) ### What form encryption is actually defending against **Form encryption** transforms readable data into ciphertext. The two common architectures are **symmetric encryption** and **asymmetric encryption**. In symmetric encryption, the same secret key encrypts and decrypts the data. In asymmetric encryption, a public key encrypts and a private key decrypts it. IBM notes that the global encryption software market is projected to reach **USD 20.1 billion by 2025**, growing at a **15.1% CAGR from 2020 to 2025**, which is a useful signal that encryption has become standard infrastructure rather than a niche feature for specialized systems ([IBM on encryption](https://www.ibm.com/think/topics/encryption)). For forms, the threat model usually looks like this: - **A compromised application server:** An attacker gets code execution or shell access and reads request bodies or decrypted submissions. - **A breached database or backup:** Stored form responses leak in bulk. - **An over-permissioned admin:** Someone with dashboard or database access reads data they don't need. - **A noisy integration chain:** Webhooks, email notifications, analytics tools, and support systems receive more plaintext than necessary. - **Client-side exposure before submit:** Browser extensions, malicious scripts, or your own frontend logging can capture values before encryption ever happens. ### Why browser-side encryption changes the trust boundary If the browser encrypts sensitive values before POST, your backend stores ciphertext, not plaintext. That reduces the blast radius of a server-side compromise. It also changes who you trust. Without client-side form encryption, you trust: 1. the browser-to-server connection, 2. the application server, 3. the database, 4. the dashboard, 5. every export path. With client-side encryption, you still need TLS, but you can stop trusting several downstream systems with readable data. > The main benefit of client-side encryption isn't “more crypto.” It's a smaller trusted surface area. That doesn't make it universally correct. It just makes the tradeoff explicit. If your backend needs to inspect or route content, full client-side encryption will get in the way. If confidentiality matters more than workflow convenience, that friction is the point. ## Comparing Encryption Approaches There are three patterns frequently chosen. They solve different problems, and they break in different ways. ### TLS only This is the default for most web forms. The browser submits plaintext over an encrypted connection, and the server receives readable fields. It's appropriate when: - the data isn't highly sensitive, - the backend must inspect every field, - staff need easy access in dashboards, emails, or automations. It does nothing to limit visibility inside your own stack. If your server, admin UI, export process, or notification pipeline is overexposed, TLS won't save you. ### Field-level encryption This is the practical middle ground. You encrypt only the fields that would cause real harm if exposed, while leaving operational fields readable. Common examples: - encrypting `ssn`, `api_key`, `notes`, or `tax_id` - leaving `name`, `email`, `subject`, or `route` plaintext for triage and automation This model works well when you still need: - email routing - webhook filtering - dashboard search - basic analytics by category or source The downside is design complexity. You need discipline around which fields stay readable, and teams often underestimate how quickly “just one more plaintext field” turns into a lot of plaintext. ### End-to-end or full-form encryption This model encrypts the whole submission payload in the browser. The backend stores ciphertext and can't read the contents unless it also holds the decryption secret, which defeats much of the point. A meaningful milestone in this space was the move from simple protected access to actual browser-based end-to-end handling. Jotform's Encrypted Forms 2.0, for example, uses **end-to-end data encryption** and the **Web Crypto API**, so only users with the unique password or access code can read submissions. This is the strongest option for confidentiality. It's also the most disruptive to downstream workflows. ### Encryption approach comparison | Approach | Where Data is Encrypted | Protects Against | Integration Impact | |---|---|---|---| | TLS only | In transit between browser and server | Network interception during transport | Low impact. Backend can read and route everything | | Field-level encryption | Selected fields in the browser or app before storage | Exposure of the most sensitive fields in storage, backups, exports, and some internal access paths | Moderate impact. Plaintext fields can still drive webhooks, email, and dashboards | | End-to-end or full-form encryption | Entire payload in the browser before submission | Server-side and storage-side exposure of submission contents, assuming keys are kept separate | High impact. Most automations can't inspect content without a decryption step | ### What tends to work in practice For JAMstack sites, the decision usually comes down to one question: **does the backend need to read the sensitive fields to do useful work?** If the answer is no, full-form encryption is clean and defensible. If the answer is yes, field-level encryption is usually the better engineering choice. It preserves enough plaintext for routing while sharply reducing what leaks if an inbox, dashboard export, or integration gets messy. ## Implementing Client-Side Encryption in JavaScript The browser already gives you the main tool you need: the **Web Crypto API**. You don't need a large crypto library to encrypt a form payload before submit. For a static site, the usual pattern is: 1. Intercept submit. 2. Serialize the fields you want to protect. 3. Encrypt in the browser with a public key. 4. POST the ciphertext to your form endpoint. 5. Decrypt later with the private key in a controlled environment. ### Plain HTML and JavaScript example This example encrypts the sensitive fields in the browser and sends only ciphertext plus a couple of routing fields in plaintext. RSA-OAEP on its own can only encrypt a couple of hundred bytes, so it uses hybrid encryption: the fields are encrypted with a fresh AES-GCM key, and only that key is encrypted with your RSA public key. To decrypt, unwrap the AES key with the private key, then decrypt the payload with it and the `iv`. ```html
``` ### Why this pattern is useful A few implementation choices matter here: - **Plaintext routing fields:** `category` remains readable so your backend or webhook can still route the submission. - **Honeypot support:** the hidden `honeypot` field helps catch simple bots. Static Forms treats any field whose name contains `honeypot` as a trap and silently discards submissions that fill it. - **Spam protection:** reCAPTCHA v2 or v3, Cloudflare Turnstile, or Altcha still work because bot checks happen around the submission flow, not inside the encrypted payload. - **Server compatibility:** your form backend doesn't need to understand the encrypted fields. It just stores or forwards the ciphertext. > Keep the encrypted payload separate from operational metadata. Mixing both into one opaque blob makes support and routing harder than it needs to be. ### React and Next.js example In React, wrap the encryption logic in a submit handler or custom hook so the component stays readable. ```jsx import { useState } from 'react'; const publicKeyPem = `-----BEGIN PUBLIC KEY----- YOUR_PUBLIC_KEY_HERE -----END PUBLIC KEY-----`; function pemToArrayBuffer(pem) { const base64 = pem .replace('-----BEGIN PUBLIC KEY-----', '') .replace('-----END PUBLIC KEY-----', '') .replace(/\s+/g, ''); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } async function importPublicKey(pem) { return crypto.subtle.importKey( 'spki', pemToArrayBuffer(pem), { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); } function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary); } // RSA-OAEP with a 2048-bit key can only encrypt about 190 bytes, so encrypt the data // with a fresh AES-GCM key and use RSA-OAEP only to encrypt that key. async function hybridEncrypt(publicKey, data) { const aesKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, aesKey, new TextEncoder().encode(JSON.stringify(data)) ); const wrappedKey = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, publicKey, await crypto.subtle.exportKey('raw', aesKey) ); return { encryptedPayload: arrayBufferToBase64(ciphertext), encryptedKey: arrayBufferToBase64(wrappedKey), iv: arrayBufferToBase64(iv) }; } export default function SecureForm() { const [status, setStatus] = useState('idle'); async function handleSubmit(event) { event.preventDefault(); setStatus('submitting'); const form = new FormData(event.currentTarget); const sensitive = { name: form.get('name'), email: form.get('email'), message: form.get('message') }; try { const publicKey = await importPublicKey(publicKeyPem); const encrypted = await hybridEncrypt(publicKey, sensitive); const body = { apiKey: form.get('apiKey'), category: form.get('category'), ...encrypted }; const res = await fetch('https://api.staticforms.dev/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); setStatus(res.ok ? 'success' : 'error'); } catch (error) { console.error('Encryption or submission failed', error); setStatus('error'); } } return (
``` ### What not to do A few patterns consistently make form autofill worse: - **Placeholder-only forms:** placeholders disappear as soon as users type, and they're weak signals compared with real labels. - **Random internal names:** `field_1`, `inputA`, `customerValue` don't communicate meaning. - **Custom div-based controls:** if it isn't a real form control, browser autofill may ignore it entirely. - **JavaScript rewriting attributes after render:** if your framework mutates `name` or `autocomplete` late, some autofill engines won't track the field properly. If a field maps to real user identity data, annotate it directly and keep the markup boring. Boring wins here. ## Building Autofill-Friendly Forms in JS Frameworks A small omission in a form component can cost real submissions. In production, I've seen teams ship polished React and Vue forms that looked correct, passed visual QA, and still lost autofill because the component layer dropped `name`, failed to forward `autocomplete`, or changed IDs during hydration. Framework code does not stop autofill on its own. Component abstractions, client-side rendering decisions, and custom form handling do. The practical rule is simple. Keep the HTML that browsers and password managers expect. Your framework should wrap native controls, not hide them behind abstractions that rewrite semantics. ![Screenshot from https://www.staticforms.dev](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/screenshots/e870d136-ddc2-4a92-9332-01e064f19651/form-autofill-static-forms.jpg) ### React example that preserves the real HTML A reusable input component is fine if it forwards the attributes autofill relies on. The failures usually come from design-system components that treat `name` and `autoComplete` as optional presentation props instead of field identity. ```jsx function Input({ label, id, name, type = "text", autoComplete, required = false }) { return (
); } export default function ContactForm() { return (
``` This works because every field earns its place. Name identifies the sender, email gives you a reply path, and message captures intent. ### React signup form For React, keep local state minimal unless you need custom validation or optimistic UI. ```jsx import { useState } from "react"; export default function SignupForm() { const [email, setEmail] = useState(""); return (
setEmail(e.target.value)} required />
); } ``` For a short form, native form submission is often enough. You don't need a fetch wrapper just because you're in React. ### Vue quick inquiry form Vue works well for adding just enough interactivity without turning the form into an app. ```vue ``` > Keep short forms narrow in purpose. A contact form that also tries to qualify budget, company size, and project scope usually belongs on a different page. Use short forms when the page promise is simple and the next step can happen after the first reply. ## When to Use Long Forms with Code Examples Long forms belong in workflows where incomplete submissions create real work. Quote requests, onboarding questionnaires, applications, and detailed support tickets all fit. The user already expects effort, so the job is to make that effort legible and manageable. ![A professional working at a desk with a computer displaying data while taking notes in a notebook.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/4f5b5fe2-9d10-4b0b-adfc-67d0a69ff165/short-form-vs-long-form-workspace-setup.jpg) ### A long form is a reliability problem This is the part teams underestimate. Long forms aren't just short forms with extra fields. They have more validation branches, more opportunities for ambiguous labels, more state to preserve, and more chances to lose users during correction. There's a useful analogy from AI systems. [LongGenBench](https://arxiv.org/html/2409.02076v6), a benchmark for long-form generation up to **16K and 32K tokens** across **four scenarios** and **three instruction types**, shows that even advanced models with **32K–128K** context windows still degrade on long-form tasks, with failures like premature termination, incomplete responses, instruction disregard, and repetitive output. That same principle shows up in forms. As state grows, reliability gets harder. ### Multi-step quote request in HTML Break the form into meaningful groups even if you don't fully split it into separate routes. ```html
Contact details
Project details
``` ### Next.js with conditional fields Conditional fields let you keep the form detailed without making every user answer every question. ```jsx "use client"; import { useState } from "react"; export default function ProjectForm() { const [isBusiness, setIsBusiness] = useState("yes"); return (
{isBusiness === "yes" && ( <> )}
``` If spam-heavy public forms are part of the plan, pair the database approach with a proper anti-bot layer. This guide on [spam email bots on forms](https://www.staticforms.dev/blog/spam-email-bot) is worth a read before you expose a write endpoint directly. ## 3. AWS Amplify ![AWS Amplify](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/screenshots/4c4582ce-f41d-4610-b2ec-fdba1f83fe74/backend-as-a-service-examples-supabase-landing-page.jpg) [AWS Amplify](https://aws.amazon.com/amplify/) makes sense when you want a frontend-friendly workflow but know you may end up deeper in AWS. Under the hood, you're dealing with Cognito, AppSync, Lambda, API Gateway, S3, and other AWS services. Amplify gives you a structured way to stitch them together without starting from raw infrastructure. This is the most “full cloud platform in BaaS clothing” option on the list. That's good if your team already speaks AWS. It's less good if you just wanted a contact form and now you're reading IAM docs. ### Good fit for teams that want AWS, not just a backend The strongest case for Amplify is long-term alignment with AWS. Security, permissions, hosting, APIs, and storage can all evolve without a migration to another ecosystem. There's also a real example of that speed advantage. In a Neiman Marcus omnichannel app launch, the team initially estimated four months, then reported a 50% increase in speed to market and a 90% reduction in development costs by using AWS Amplify, Amazon Cognito, and AWS AppSync ([Neiman Marcus BaaS case study summary](https://acropolium.com/blog/baas-use-cases/)). - **Best for AWS-native organizations:** It's easier when your company already uses AWS accounts, policies, and services. - **Less friendly for casual use:** Pricing and architecture are spread across multiple underlying services. - **Strong integration story:** Auth, APIs, storage, hosting, and CI/CD can live in one ecosystem. > With Amplify, you're not really choosing a lightweight utility. You're choosing an opinionated ramp into AWS. ### Next.js example with Amplify Auth ```jsx "use client"; import { useState } from "react"; import { Amplify } from "aws-amplify"; import { signIn } from "aws-amplify/auth"; Amplify.configure({ Auth: { Cognito: { userPoolId: "us-east-1_example", userPoolClientId: "exampleclientid", }, }, }); export default function LoginForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); // handed straight to the auth SDK, never to a form endpoint async function handleSubmit(e) { e.preventDefault(); await signIn({ username: email, password }); } return (
setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Password" />
); } ``` If your JAMstack app also pushes events into other systems, understanding webhook behavior matters. This short explainer on [how webhooks work](https://www.staticforms.dev/blog/what-is-a-webhook-and-how-does-it-work) is useful context. ## 4. Appwrite ![Appwrite (Cloud)](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/screenshots/76ebe651-2d3d-4f8f-9da6-59c0c7ae9e74/backend-as-a-service-examples-aws-amplify.jpg) [Appwrite](https://appwrite.io/) sits in a useful middle ground. It gives you databases, auth, storage, functions, and webhooks in one consistent API surface, and it keeps the self-hosting door open. That combination matters for teams that want a managed service now but don't want to bet everything on one hosted provider forever. Compared with Firebase or Amplify, Appwrite feels more unified out of the box. Compared with Supabase, it feels less centered on SQL-first workflows and more centered on a broad backend toolkit. ### Why teams pick it Appwrite is attractive when you want one backend product that covers common app features without forcing you into a giant cloud ecosystem. The cloud product also adds operational features like observability and spending controls, which can be helpful for agencies or startups trying to avoid billing surprises. There's a practical trade-off, though. Appwrite is broad, but broad products need careful sizing. Functions, builds, and storage patterns still require design decisions once your traffic stops being trivial. - **Unified API feel:** Less stitching across separate vendor products. - **Self-host parity:** You can move to your own infrastructure without rewriting the app around a different backend abstraction. - **Not the best fit for SQL-heavy modeling:** If deep relational querying is central, Supabase or another Postgres-first option may feel cleaner. ### Plain HTML example posting to an Appwrite function endpoint ```html
``` In practice, development teams typically won't submit directly like this without auth headers or a client SDK. But architecturally, that's the model you're buying: frontend clients talking to hosted backend primitives without your own custom server sitting in the middle. ## 5. Nhost ![Nhost](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/screenshots/72adb772-6fb9-4aeb-9df1-7917ac050397/backend-as-a-service-examples-appwrite-platform.jpg) [Nhost](https://nhost.io/) is the choice for teams that want GraphQL front and center. It combines Postgres, Hasura, auth, storage, and functions into a stack that feels very good when your client app expects GraphQL subscriptions, roles, and generated operations from day one. That's a narrower fit than Firebase or Supabase, but it's a real one. If your frontend already uses GraphQL heavily, Nhost can remove a lot of backend assembly work. ### Strong when GraphQL is a feature, not a preference Some teams say they want GraphQL when they really just want an API. That's not the same thing. Nhost is worth it when GraphQL shapes how you build, test, and ship the product. Its role-based approach can be productive, but GraphQL systems need discipline. You still have to think about permission boundaries, query complexity, and how much logic belongs in database actions versus functions. > Teams that already understand Hasura patterns usually get value from Nhost quickly. Teams that don't may spend the first phase learning the stack instead of shipping. ### React example with a GraphQL mutation ```jsx import { useState } from "react"; export default function ContactMutation() { const [form, setForm] = useState({ name: "", email: "", message: "" }); async function handleSubmit(e) { e.preventDefault(); await fetch("https://YOUR_NHOST_SUBDOMAIN.nhost.run/v1/graphql", { method: "POST", headers: { "Content-Type": "application/json", "x-hasura-admin-secret": "YOUR_ADMIN_SECRET" }, body: JSON.stringify({ query: ` mutation InsertMessage($name: String!, $email: String!, $message: String!) { insert_contact_messages_one(object: { name: $name, email: $email, message: $message }) { id } } `, variables: form }) }); } return (
setForm({ ...form, name: e.target.value })} placeholder="Name" /> setForm({ ...form, email: e.target.value })} type="email" placeholder="Email" />
``` If you inherited a Parse app, Back4App is often the shortest path to getting it under control without rebuilding the backend from scratch. ## 7. Static Forms You ship a JAMstack marketing site on Friday, then someone asks for a contact form, file upload, spam filtering, and Slack notifications by Monday. In that situation, a full backend is usually the wrong tool. Static Forms fits because it handles a narrow job well: receiving form submissions from static sites without forcing you to stand up a database, auth layer, or server code. That narrower scope is why it belongs in a BaaS roundup. A lot of backend as a service examples are really application platforms. Static Forms is a specialized service, and that distinction matters if you are building with React, Vue, Astro, Next.js static export, or plain HTML on the JAMstack. ### Best when forms are the backend For brochure sites, docs sites, portfolios, waitlists, lead-gen pages, and small client projects, the backend requirement is often simple. Accept a POST request, block obvious spam, send notifications, store submissions, and give the team a way to export or forward data. Reaching for Firebase, Supabase, or Amplify for that job adds setup and maintenance you may never use. Static Forms keeps the integration surface small. You get a submission endpoint that works with plain HTML and frontend frameworks, so it drops cleanly into existing static projects. The practical parts are covered too: - **Spam protection:** Supports reCAPTCHA v2/v3, Cloudflare Turnstile, Altcha, and honeypot fields; reCAPTCHA v3, Turnstile, and Altcha need the Starter plan or above. - **GDPR handling:** Includes export, deletion, and consent-related controls. - **Email delivery setup:** Custom-domain sending supports SPF, DKIM, and DMARC. - **Integrations:** You can send submissions to email on any plan, to webhooks, Slack, Discord, and Telegram from the Starter plan up, and to Google Sheets, Notion, Airtable, and Mailchimp from Pro up. - **File uploads:** From the Starter plan up, supports file uploads up to 4.5 MB per submission, counting every file plus the other fields. The trade-off is straightforward. You get a fast path for forms, but you are not getting a general-purpose backend. No app database, no auth model, no relational queries, no server-side business logic beyond the form workflow itself. ### Plain HTML example ```html
``` This is the main appeal. A static site can submit directly without adding serverless functions just to relay form data. ### React example with AJAX submit ```jsx import { useState } from "react"; export default function ContactForm() { const [status, setStatus] = useState(""); async function handleSubmit(e) { e.preventDefault(); const formData = new FormData(e.target); const response = await fetch("https://api.staticforms.dev/submit", { method: "POST", body: formData, }); if (response.ok) { setStatus("Message sent"); e.target.reset(); } else { setStatus("Submission failed"); } } return (

{{ status }}

``` The Vue example shows the same pattern. Frontend-first integration, minimal moving parts, and no need to provision backend infrastructure for a basic workflow. That makes Static Forms a good specialized choice for JAMstack projects, especially if the only dynamic feature is a form. If the site is becoming a product with users, dashboards, permissions, and application data, choose a full-stack BaaS instead. If the project just needs reliable submissions and clean integrations, a focused service is often the better engineering decision. For a concrete implementation walkthrough, see this guide to [sending form submissions to email](https://www.staticforms.dev/blog/form-submission-to-email). ## Top 7 BaaS Platforms Comparison | Service | Implementation complexity | Resource requirements | Expected outcomes | Ideal use cases | Key advantages | |---|---:|---|---|---|---| | Firebase (Google) | Low–Medium (polished SDKs, minimal setup) | Fully managed Google infrastructure; may increase Cloud billing | Global real-time sync, offline support, analytics & messaging | Real-time mobile/web apps, rapid prototypes, teams extending into Google Cloud | Mature SDKs/docs, global scale, tight Google Cloud integration | | Supabase | Medium (requires SQL/DB knowledge) | Managed Postgres; moderate compute; self-host option available | SQL-based data modeling with REST/GraphQL APIs and RLS security | Teams needing full SQL, portability, and predictable data models | Open-source core, Postgres + RLS, easy to self-host | | AWS Amplify | Medium–High (AWS concepts and tooling) | Uses AWS services (Cognito, AppSync, Lambda, S3); requires AWS familiarity | Enterprise-grade backends with fine-grained service control and scaling | Organizations already on AWS or requiring enterprise compliance | Deep AWS ecosystem integration and clear upgrade path to enterprise | | Appwrite (Cloud) | Low–Medium (unified API surface) | Managed all-in-one backend with hosting/observability; can self-host | Unified backend API, hosting, observability and budget controls | Teams wanting an all-in-one API and option to self-host | Transparent limits/pricing, self-host parity, bundled observability | | Nhost | Medium (GraphQL/Hasura patterns) | Managed Postgres + Hasura; includes observability; moderate compute | GraphQL-first CRUD/realtime backed by Postgres, optional AI tooling | GraphQL-first teams needing strong Postgres integration and observability | Tight Hasura+Postgres integration, clear pricing, AI/pgvector options | | Back4App (Managed Parse) | Low (schema-driven Parse model) | Managed Parse Server with autoscaling and backups; migration support | Schema-driven BaaS with dashboard, cloud code and managed ops | Teams familiar with Parse wanting managed operations or migration path | Mature Parse ecosystem, easy free-to-scale path, self-host mobility | | Static Forms | Very Low (drop-in endpoint) | Hosted single endpoint; minimal ops; file uploads from Starter, limited in size | Instant form handling: email/dashboard storage, CSV export, integrations | Static/JAMstack sites, agencies, freelancers needing simple forms | Zero-backend setup, broad builder support, multi-destination routing | ## Making the Call Which BaaS Is Right for You A lot of teams overbuy backend infrastructure. If your product needs auth, permissions, stored application data, background jobs, and room to grow, choose a full-stack BaaS that fits how your team already works. Firebase is still a practical pick for realtime apps and mobile-heavy products. Supabase fits teams that want Postgres, SQL, and fewer portability concerns later. Amplify makes more sense when AWS is already the default for security, deployment, and compliance decisions. That decision gets simpler if you frame it around ownership. A full-stack BaaS gives you one place to handle data, auth, storage, and server logic, but it also pulls more of your application architecture into that platform's model. Specialized services do less, which is often the point. For JAMstack projects, that trade-off matters. A React or Vue frontend may only need one backend capability, not an entire backend stack. Form handling is the clearest example. Contact forms, lead capture, quote requests, event signups, and intake flows do not always justify standing up auth rules, database tables, and functions just to receive a POST request and route the result somewhere useful. That is where a narrower tool earns its keep. Static Forms covers the form backend job directly: submission handling, spam filtering, file uploads and webhook delivery (Starter and up), email notifications, exports, and custom-domain email setup (Pro and up). For a static site, that usually means less code to maintain and fewer failure points than wiring a general BaaS into a feature that only needs reliable form submission. If your site just needs forms that work, Static Forms is one of the fastest paths I'd consider. You can post to a hosted endpoint from plain HTML or from framework code in React, Next.js, Vue, Astro, Webflow, or WordPress, then add integrations and delivery rules later without rebuilding the rest of the stack. --- ## The Best Form Backends for Hugo and Jekyll in 2026 Source: https://www.staticforms.dev/blog/best-form-backends-hugo-jekyll Date: 2026-06-14 Author: Static Forms Team Hugo and Jekyll generate static sites with no server behind them. That's exactly why they're fast and cheap to host — and exactly why a plain `
` has nowhere to send its data. To make contact forms work, you point them at a *form backend*: a hosted endpoint that receives the submission, blocks spam, and emails it to you (or forwards it onward). There are a handful of ways to do this, and they're not equal for static sites built with Hugo or Jekyll. This guide walks through the realistic options, what each is good and bad at, and how to pick. ## What a form backend actually has to do Before comparing options, it helps to be clear about the job. For a Hugo or Jekyll site, a good form backend needs to: - **Accept a submission from a purely static page** — no server-side code on your side. - **Filter spam** without forcing a clunky CAPTCHA on every visitor. - **Deliver reliably to email**, with a usable reply-to address. - **Optionally route data elsewhere** — a spreadsheet, Slack, a webhook. - **Not lock you in** — your form is just HTML posting to a URL, so switching should be trivial.
Which approach fits your static site?
🔌
Hosted form backend (recommended)
Post HTML to an endpoint. Zero server, spam filtering and email built in. Best fit for Hugo & Jekyll.
🏗️
Host-bundled forms
Convenient if you stay on one host — but ties your form to that platform.
⚙️
Roll your own serverless function
Maximum control, but you own deliverability, spam, and maintenance.
📧
mailto: link
No backend at all — but it's not really a form. Avoid for anything real.
## Option 1: A hosted form backend (best fit for most sites) This is the approach built for exactly this problem. You add a normal HTML form to your Hugo partial or Jekyll include, set the `action` to a hosted endpoint, and you're done. The service receives the POST, screens it for spam, and emails you the message. **Why it suits Hugo and Jekyll:** there's nothing to deploy or maintain on your end, it works identically on every host, and your form stays portable — it's just markup pointing at a URL. [Static Forms](https://www.staticforms.dev) is built around this model. A working form is a single HTML block: ```html
``` The `honeypot` field catches the bulk of spam bots without a CAPTCHA, `replyTo="@"` makes the submitter's address your reply-to, and there's a free tier to start. On paid plans you can also forward submissions to [Slack](https://www.staticforms.dev/integrations/slack) or any endpoint via [webhooks](https://www.staticforms.dev/integrations/webhooks) (Starter and up), or to a [Google Sheet](https://www.staticforms.dev/integrations/google-sheets) (Pro and up). Other hosted backends in this category include Formspree, Getform, Basin, and Web3Forms — the model is similar across them, so the differentiators are spam handling, deliverability, integrations, and pricing. **Best for:** almost any Hugo or Jekyll site. It's the lowest-effort option that still does the job properly. ## Option 2: Forms bundled with your host Some static hosts bundle a form-handling feature — Netlify Forms is the best-known example, and Cloudflare and others have their own takes. You add an attribute or special markup, and the host intercepts submissions during the build or at the edge. **The upside:** it's convenient if you're already on that host and never plan to leave. **The catch:** your form is now coupled to the platform. Move your Hugo or Jekyll site to a different host and the form stops working until you rewire it. There are also usually tight limits on submissions before you hit a paid tier. **Best for:** sites firmly committed to one host, with light form volume. ## Option 3: Your own serverless function If you want total control, you can write a small serverless function — on Vercel, Cloudflare Workers, AWS Lambda, or similar — that receives the POST, sends an email through a provider, and screens for spam. Your Hugo or Jekyll form posts to that function's URL. **The upside:** you own the whole pipeline and can do anything. **The cost:** you now own *everything* — email deliverability (and the SPF/DKIM headaches that come with it), spam filtering, error handling, and ongoing maintenance. For a contact form, that's a lot of surface area to babysit. **Best for:** teams with specific custom logic and the appetite to maintain it. ## Option 4: A plain mailto: link (not really a form) The zero-backend "solution" is a `mailto:` link or a form with `action="mailto:..."`. It opens the visitor's email client instead of submitting anywhere. In practice it's a poor experience: it depends on the visitor having a configured desktop mail client, exposes your address to scrapers, and captures nothing structured. It's fine as a fallback link, not as your contact form. **Best for:** a quick "email me" link — not a real contact form. ## Feature comparison at a glance Here's how the four approaches stack up on the things that actually matter for a Hugo or Jekyll site: | | Hosted backend | Host-bundled | Own serverless | mailto: | |---|---|---|---|---| | **Server code to maintain** | None | None | Yes — you own it | None | | **Works on any host** | ✅ | ❌ tied to host | ✅ | ✅ | | **Built-in spam filtering** | ✅ | Partial | You build it | ❌ | | **Email delivery handled** | ✅ | ✅ | You configure SPF/DKIM | N/A | | **Route to Sheets / Slack / webhooks** | ✅ | Limited | You build it | ❌ | | **Captures structured data** | ✅ | ✅ | ✅ | ❌ | | **Setup time** | Minutes | Minutes | Hours+ | Minutes | | **Portable (just HTML)** | ✅ | ❌ | Mostly | ✅ | The pattern is clear: a hosted backend gives you the least to maintain while still ticking every box that matters. The host-bundled option trades portability for convenience, and rolling your own trades time and maintenance for control. ## What about spam? Spam is the number-one reason a naive contact form becomes unusable, so it deserves its own look. A static site can't run server-side filtering on its own, which leaves three layers worth understanding: - **Honeypot fields.** A hidden input that humans never fill but bots do. It's invisible to visitors, requires no extra clicks, and catches the bulk of automated spam. Every example in our Hugo and Jekyll tutorials includes one. - **Server-side screening.** A good hosted backend scores submissions and drops obvious junk before it ever reaches your inbox — something a `mailto:` link or a bare serverless function won't do unless you build it. - **CAPTCHA, only when needed.** If sophisticated spam slips through, you can add a challenge like reCAPTCHA, Cloudflare Turnstile, or Altcha. The trick is to treat it as a fallback rather than a default, since every CAPTCHA adds friction for real visitors. The right order is honeypot first, server-side screening always, and a CAPTCHA only if the first two aren't enough. ## How to choose For the large majority of Hugo and Jekyll sites, a **hosted form backend (Option 1)** is the right answer: it matches the static-site model exactly, requires no infrastructure, and keeps your form portable across hosts. Reach for a host-bundled option only if you're locked to one platform anyway, or a custom serverless function only when you genuinely need custom logic and will maintain it. If you want to see the hosted approach end to end, we have step-by-step tutorials for [Jekyll on GitHub Pages](https://www.staticforms.dev/blog/jekyll-contact-form-github-pages) and [Hugo](https://www.staticforms.dev/blog/hugo-contact-form-tutorial), plus a general primer on [sending form data without a server](https://www.staticforms.dev/blog/html-form-send-email-without-server). ## Switching is painless One underrated advantage of the hosted approach: there's almost nothing to migrate. Your form is just an HTML `
` with an `action` URL and a few hidden fields. Moving from one backend to another — or moving your whole Hugo or Jekyll site to a new host — means changing the endpoint URL and the key, and nothing else. Compare that to a host-bundled solution, where leaving the host means rebuilding form handling from scratch, or a custom serverless function, where the whole pipeline moves with you. Keeping the form as portable markup is the safest long-term bet for a static site. ## Frequently asked questions **Do I need to know any backend programming?** No. With a hosted backend your only job is to write the HTML form and point it at an endpoint. The server-side work is handled for you. **Will a form backend slow down my static site?** No. The form is static HTML that loads with the page. The backend only does work when someone actually submits, and that request goes directly from the visitor's browser to the API — your site stays as fast as ever. **Can I use the same backend for Hugo and Jekyll?** Yes. The approach is identical across static site generators — only the templating (a Hugo partial vs. a Jekyll include) differs. The form markup and endpoint are the same. **Is there a free option?** Yes — Static Forms has a free plan that's plenty for a typical contact form. You can see the limits and paid tiers on the [pricing page](https://www.staticforms.dev/pricing). ## Wrapping up A static site doesn't mean a static contact page. The cleanest way to make forms work on Hugo and Jekyll is to keep the form as plain HTML and let a hosted backend do the server-side work — so you keep the speed and simplicity that made you choose a static generator in the first place. [Start free with Static Forms](https://www.staticforms.dev) and have a working form on your site in a few minutes. --- ## How to Add a Contact Form to a Hugo Site (No Backend Required) Source: https://www.staticforms.dev/blog/hugo-contact-form-tutorial Date: 2026-06-14 Author: Static Forms Team Hugo builds blazing-fast static sites in seconds, which is exactly why it's a favorite for blogs, docs, and marketing sites. But that speed comes from generating plain HTML at build time — there's no running server to catch a form submission. So the first time a visitor wants to email you through a contact page, you hit a wall: Hugo has no built-in way to process forms. The clean solution is to post your form to a form backend that handles validation, spam filtering, and email delivery for you. In this tutorial you'll add a working contact form to a Hugo site using the [Static Forms form backend](/), package it as a reusable partial and shortcode, and wire it up so it works with any theme — including popular ones like PaperMod and Ananke. ## Why Hugo needs a form backend Hugo's whole model is "render everything ahead of time." When someone hits your site, they're downloading static files from a CDN — fast and secure, but with nothing on the other end to receive a `POST`. A form backend fills that gap by giving you a single endpoint to submit to. Hugo's job is just to render the ``; the backend does the rest.
One partial, reused everywhere
layouts/partials/
contact-form.html
Define the form once
→
Contact page
Footer
Any blog post (shortcode)
→
📬
Static Forms API
→ your inbox
## Prerequisites You'll need: - A Hugo site (any theme) that builds locally with `hugo server` - A Static Forms account — [sign up for free](https://www.staticforms.dev) - Your API key from the [Static Forms dashboard](https://www.staticforms.dev/app/forms) ## Step 1: Store your API key in site config Keep the key in one place so you never hard-code it across templates. Add it to `hugo.toml` (or `config.toml` on older Hugo versions): ```toml [params] staticFormsKey = "YOUR_API_KEY_HERE" ``` This is a public form key — it ships in your site's HTML like any client-side form field. It only authorizes submissions to your account, so it's safe to commit. ## Step 2: Create a reusable partial Hugo partials are the equivalent of includes. Create `layouts/partials/contact-form.html`: ```go-html-template
``` The key fields: - **`apiKey`** pulls your key from site config and routes the submission to your account. - **`replyTo`** set to `@` uses the visitor's email as the reply-to address, so you reply right from your inbox. - **`honeypot`** silently drops bot submissions — bots fill every field, humans never see this one. ## Step 3: Add a contact page Create `content/contact.md`. Hugo doesn't render raw HTML inside Markdown by default, so the cleanest approach is to call the partial from a template. If your theme has a single-page layout you can extend, add the partial there; the most portable route is a dedicated layout. Create `layouts/page/contact.html`: ```go-html-template {{ define "main" }}

{{ .Title }}

{{ .Content }} {{ partial "contact-form.html" . }}
{{ end }} ``` Then set the layout in the page's front matter: ```yaml --- title: "Contact" layout: "contact" --- Have a question? Drop me a line below. ``` ## Step 4: Use it anywhere with a shortcode To let yourself (or other authors) embed the form inside any Markdown post, wrap the partial in a shortcode. Create `layouts/shortcodes/contact.html`: ```go-html-template {{ partial "contact-form.html" . }} ``` Now any content file can include the form inline: ```markdown Want to work together? {{}} ``` ## Step 5: Theme-specific notes The partial works with any theme, but a couple of popular ones have a natural home for it: - **PaperMod** — PaperMod ships with a `single.html` layout. The simplest path is the dedicated `layouts/page/contact.html` above, which overrides the theme for just that page. Don't edit files inside `themes/` directly; put your overrides in your project's root `layouts/` so theme updates don't clobber them. - **Ananke** — Ananke renders page content through `.Content`. The dedicated layout approach works the same way. If you'd rather keep everything in Markdown, enable raw HTML by adding `[markup.goldmark.renderer]` with `unsafe = true` to your config — but the partial-and-shortcode approach keeps your content clean and avoids that flag. ## Step 6: Build, deploy, and test Run your normal build and deploy — Hugo sites work the same on Netlify, Cloudflare Pages, GitHub Pages, or any static host: ```bash hugo --minify ``` Open your contact page on the deployed site and send a test message. The first submission triggers a one-time verification email from Static Forms — click the link, and every submission afterward flows straight to your inbox. By default the API returns a plain success response. To send visitors to a thank-you page instead, add a redirect field to the partial: ```go-html-template ``` The full field reference lives in the [Static Forms documentation](https://www.staticforms.dev/docs/html). ## Step 7: Style the form to match your theme The partial inherits your theme's styles, but a contact form usually deserves a bit of intention. Add a stylesheet at `assets/css/contact-form.css` and pull it into your form layout, or drop the rules into your theme's main stylesheet: ```css .contact-form { display: flex; flex-direction: column; gap: 1rem; max-width: 32rem; } .contact-form input, .contact-form textarea { width: 100%; padding: 0.65rem 0.75rem; font: inherit; border: 1px solid #cbd5e1; border-radius: 6px; } .contact-form input:focus, .contact-form textarea:focus { outline: 2px solid #7c3aed; outline-offset: 1px; border-color: transparent; } .contact-form button { align-self: flex-start; padding: 0.65rem 1.25rem; font: inherit; font-weight: 600; color: #fff; background: #7c3aed; border: none; border-radius: 6px; cursor: pointer; } ``` If you're on a recent Hugo with the asset pipeline, fingerprint and link it from your form layout: ```go-html-template {{ with resources.Get "css/contact-form.css" | minify | fingerprint }} {{ end }} ``` ## Submit without leaving the page (AJAX) The plain form works with zero JavaScript. If you'd rather keep visitors on the page, submit with `fetch()`. Create a small script — for example `assets/js/contact-form.js` — and include it from your form layout: ```javascript const form = document.querySelector('.contact-form'); form?.addEventListener('submit', async (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(form).entries()); try { const response = await fetch('https://api.staticforms.dev/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, replyTo: '@' }), }); const result = await response.json(); if (result.success) { form.outerHTML = '

Thanks — your message is on its way!

'; } else { throw new Error(result.message); } } catch { form.insertAdjacentHTML( 'beforeend', '

Something went wrong. Please try again.

' ); } }); ``` Because the `apiKey` is already a hidden field in the partial, `new FormData(form)` picks it up automatically — no need to repeat it in the script. The honeypot rides along in the payload too, so spam protection still applies. ## Multilingual contact forms Hugo's first-class multilingual support is one of its biggest draws, and your contact form should follow suit. Move the user-facing strings into your i18n translation files (`i18n/en.toml`, `i18n/fr.toml`, and so on): ```toml # i18n/en.toml [contactName] other = "Name" [contactMessage] other = "Message" [contactSend] other = "Send message" ``` Then reference them in the partial with the `i18n` function: ```go-html-template ``` The same single endpoint handles every language — only the labels change. You can even set a per-language email subject so you know which site version a message came from. ## Going further - **Spreadsheets and tools.** Forward submissions to a sheet with the [Google Sheets integration](https://www.staticforms.dev/integrations/google-sheets) (Pro plan and up), or send them anywhere via [webhooks](https://www.staticforms.dev/integrations/webhooks) (Starter plan and up). - **AJAX submission.** Prefer an inline success message over a page navigation? Submit with `fetch()` — the pattern is the same one we use in the [JAMstack contact form guide](https://www.staticforms.dev/blog/jamstack-contact-form). - **Other generators.** Using more than one static site generator? We have matching tutorials for [Astro](https://www.staticforms.dev/blog/astro-contact-form-tutorial) and other frameworks. ## Troubleshooting The most common snags on a Hugo setup: - **`partial "contact-form.html" not found`.** The partial must live at `layouts/partials/contact-form.html` in your project root (not inside `themes/`). Hugo resolves project-level layouts first, which is also how you safely override a theme. - **Raw HTML in Markdown is being stripped.** Hugo's Goldmark renderer drops raw HTML by default for safety. That's why this guide uses a partial and a shortcode instead of pasting `
` into a `.md` file. If you must inline HTML, set `markup.goldmark.renderer.unsafe = true` in your config — but the partial approach is cleaner. - **The form submits but no email arrives.** Confirm the `action` is exactly `https://api.staticforms.dev/submit` and your key is correct, then check for the one-time verification email (including spam) and click its link. - **The shortcode prints literally instead of rendering.** Make sure you're using the shortcode syntax with percent or angle brackets — `{{}}` — and that the file is at `layouts/shortcodes/contact.html`. - **Styles aren't applying.** If you use the asset pipeline snippet, confirm the CSS file is under `assets/` (not `static/`) — `resources.Get` only reads from `assets/`. ## Frequently asked questions **Does this work with any Hugo theme?** Yes. The partial and shortcode live in your project root, so they work regardless of theme and survive theme updates. **Is the API key safe in the HTML?** Yes — it's a public form key that only authorizes submissions to your account. It ships in the page source like any client-side form field and can't be used to read your data. **Can I have more than one form on a site?** Absolutely. Reuse the same partial anywhere, and set a different `subject` value per form so you can tell submissions apart in your inbox. **What does it cost?** A free plan covers typical personal and small-business use. For higher volume, file uploads, or advanced spam controls, see the [pricing page](https://www.staticforms.dev/pricing). ## Wrapping up Hugo doesn't process forms, and it doesn't need to. Define the form once as a partial, expose it as a shortcode, and post to a form backend that handles validation, spam, and email. You keep Hugo's speed and simplicity, and your visitors get a contact form that just works — across every theme. [Create a free Static Forms account](https://www.staticforms.dev) and add a contact form to your Hugo site today. --- ## How to Add a Contact Form to a Jekyll Site on GitHub Pages Source: https://www.staticforms.dev/blog/jekyll-contact-form-github-pages Date: 2026-06-14 Author: Static Forms Team Jekyll is the static site generator that powers GitHub Pages, which is part of why it's so popular: push Markdown to a repo and your site is live. The catch shows up the moment you need a contact form. GitHub Pages serves static files only — there's no server to receive a POST request, no database to store a message, and no way to send an email. And because GitHub Pages runs Jekyll in `safe` mode, you can't install a Jekyll plugin to do it either. The fix is to hand form submissions off to a form backend. In this tutorial you'll add a fully working contact form to a Jekyll site on GitHub Pages using [Static Forms](https://www.staticforms.dev), wrap it in a reusable Jekyll include, and have submissions land in your inbox — without writing or hosting any server code. ## Why Jekyll on GitHub Pages can't process forms A traditional contact form posts to a script on your server that validates the data, stores or emails it, and returns a response. Jekyll produces only HTML, CSS, and JavaScript, and GitHub Pages serves those files as-is. There is no request handler at the other end of your ``. Here's what that submission path actually looks like once you route it through a form backend:
How a Jekyll form submission reaches your inbox
📝
Jekyll page
Static HTML built by GitHub Pages
→
🔌
Static Forms API
Validates, blocks spam, sends mail
→
📬
Your inbox
Message arrives as email
The browser POSTs the form directly to the API endpoint — GitHub Pages never has to run any code.
## Prerequisites Before you start, make sure you have: - A Jekyll site deployed to GitHub Pages (or one you can build locally) - A Static Forms account — [sign up for free](https://www.staticforms.dev) - Your API key from the [Static Forms dashboard](https://www.staticforms.dev/app/forms) ## Step 1: Build the contact form Jekyll renders Markdown, but it passes raw HTML straight through. So you can drop a standard form into any page or layout. Create a `contact.html` page at the root of your site: ```html --- layout: default title: Contact permalink: /contact/ ---

Get in touch

``` That top block between the `---` lines is Jekyll front matter — it tells Jekyll to wrap the page in your `default` layout and serve it at `/contact/`. Replace `YOUR_API_KEY_HERE` with your real key. A few fields are doing important work: - **`apiKey`** routes the submission to your account. - **`replyTo`** set to `@` tells Static Forms to use the submitter's own email as the reply-to address, so you can reply straight from your inbox. - **`honeypot`** is a hidden field. Real visitors never see it, but spam bots fill in every field they find — so any submission with a value here is silently rejected. ## Step 2: Make it a reusable include If you want the same form in your footer, your about page, and a dedicated contact page, copy-pasting the markup gets old fast. Jekyll includes solve this. Create `_includes/contact-form.html`: ```html
``` Store the key once in `_config.yml` so it isn't scattered across files: ```yaml staticforms_key: YOUR_API_KEY_HERE ``` Now drop the form anywhere with a single Liquid tag, optionally overriding the email subject per page: ```liquid {% include contact-form.html subject="Contact form — pricing page" %} ``` Because the API key is published in your site's HTML either way (that's true of any client-side form), treat it as a public form key rather than a secret. It only authorizes submissions to your account's endpoint. ## Step 3: Commit, push, and test GitHub Pages rebuilds automatically on every push to your publishing branch: ```bash git add contact.html _includes/contact-form.html _config.yml git commit -m "Add Static Forms contact form" git push origin main ``` Give the build a minute, then open `/contact/` on your live site and send yourself a test message. The first time you submit, Static Forms emails you a confirmation link to verify the form — click it once and every submission after that flows straight to your inbox. ## Step 4: Send visitors to a thank-you page By default the API returns a plain success response. For a Jekyll site you almost always want to send visitors to a friendly confirmation page instead. Add a `redirectTo` field pointing at a thank-you page you build in Jekyll: ```html ``` Then create `thank-you.html` with `permalink: /thank-you/` and whatever message you like. The full list of supported fields is in the [Static Forms documentation](https://www.staticforms.dev/docs/html). ## Step 5: Style the form to match your theme The default Jekyll theme (Minima) and most others give you sensible base styles, but a contact form usually wants a little polish. Add this to your `assets/css/style.scss` (or a ` ``` A few framework-specific gotchas are worth watching: - **Don't break the shared `name`.** That's what keeps the group behaving as radios. - **Don't replace radios with clickable divs.** Framework state doesn't excuse losing semantics. - **Scope styles carefully.** CSS Modules, scoped styles, or component classes help avoid accidental overrides. ## Making Your Styled Form Functional A polished radio group still isn't useful if the form goes nowhere. Static and JAMstack projects hit this wall all the time. The frontend is clean, the UI looks finished, then someone realizes there's no submission pipeline. The simplest fix is to post the form to a hosted form backend instead of writing your own handler. That keeps the native form flow intact, which is especially nice when your radios, validation, and labels already rely on normal HTML behavior. Here's a realistic production-style example: ```html
Reason for contact
``` The practical concerns are straightforward: - **Spam protection matters.** Use a honeypot and a challenge option such as reCAPTCHA v2, reCAPTCHA v3, or Cloudflare Turnstile if your backend supports them. - **Uploads need explicit limits.** If your form accepts files, keep the limit clear in the UI. Hosted form backends cap upload size — Static Forms accepts files from the Starter plan up and rejects any submission over **4.5MB**, counting every file plus the other fields — so match your copy and validation to the actual backend rule. - **Email delivery needs proper domain setup.** If you send from your own domain, SPF, DKIM, and DMARC need to be configured correctly or your form notifications and auto-responses can become unreliable. - **GDPR isn't just legal copy.** It affects consent wording, retention, export, and deletion workflows. Before you ship, test the whole interaction. Styled radios often pass visual review and fail usability review. [Uxia's guide to UI validation](https://www.uxia.app/blog/user-interface-design-testing) is a solid reference for checking whether a polished interface is easy to use. --- If you want to keep the frontend simple and skip writing backend form handlers, [Static Forms](https://www.staticforms.dev) is one practical option for static and JAMstack sites. You point your form at its submission endpoint, keep native HTML forms, and add things like spam protection, GDPR controls, webhooks and file uploads up to 4.5MB per submission (Starter and up), and custom-domain email delivery with SPF, DKIM, and DMARC support (Pro and up) without standing up your own server. --- ## How to Add Platform-Independent Forms to WordPress Source: https://www.staticforms.dev/blog/platform-independent-forms-wordpress Date: 2026-06-07 Author: Static Forms Team Most WordPress forms are trapped. They depend on PHP running on your server, a form plugin storing entries in your database, and `wp_mail()` hoping your host's mail configuration actually delivers. The moment you switch hosts, change form plugins, or migrate to a static site, those forms break — and every submission, integration, and notification rule has to be rebuilt from scratch. A **platform-independent form** solves this by decoupling the form from WordPress entirely. The form markup lives wherever you want, but the submission goes to a dedicated, portable endpoint. Move your site to GitHub Pages, Netlify, Astro, or a hand-coded HTML page tomorrow, and the *exact same form* keeps delivering to the *exact same inbox* — no rebuild required. In this guide, we'll show you how to add platform-independent forms to WordPress using [Static Forms](https://www.staticforms.dev), covering the official plugin, page-builder integrations, and the plain-HTML approach that works on any platform. ## What "Platform-Independent" Actually Means A WordPress form is platform-dependent when it relies on things that only exist inside WordPress: - **PHP processing** — the submission is handled by server-side code that ships with WordPress. - **Plugin-specific storage** — entries live in a database table owned by Contact Form 7, WPForms, Gravity Forms, etc. - **Host mail delivery** — notifications depend on your server's mail setup, which is famously unreliable. A platform-independent form removes all three dependencies. The form simply POSTs its fields to an external API endpoint that handles validation, spam filtering, storage, and email delivery. WordPress becomes just one place the form *happens to live* — not the thing the form *depends on*. The practical payoff: - **No lock-in** — migrate hosts or platforms without touching your form logic. - **Reliable delivery** — email is sent by infrastructure built for it, not your shared host. - **One inbox everywhere** — the same endpoint serves your WordPress site, your landing pages, and your future static site. - **No PHP to maintain or secure** — fewer plugins, smaller attack surface. ## Prerequisites Before you start, make sure you have: - A WordPress site (self-hosted or WordPress.com Business+ with plugin support) - A Static Forms account — [sign up for free](https://www.staticforms.dev) - Your API key from the [Static Forms dashboard](https://www.staticforms.dev/app/forms) You'll choose **one** of three approaches below depending on how your site is built. All three produce the same result: portable forms that post to the same endpoint. ## Approach 1: The Official WordPress Plugin (Recommended) The official Static Forms plugin is the easiest path if you already use **Elementor Pro** or **Contact Form 7**. It forwards submissions server-to-server, so your API key never reaches the browser. ### Step 1: Install the Plugin 1. Download the plugin ZIP from the [WordPress integration docs](https://www.staticforms.dev/docs/wordpress). 2. In WordPress, go to **Plugins → Add New → Upload Plugin**. 3. Choose the ZIP, click **Install Now**, then **Activate**. ### Step 2: Add Your API Key Go to **Settings → Static Forms** and paste the API key from your [Static Forms dashboard](https://www.staticforms.dev/app/forms). This is stored once, server-side, and reused by every integration mode. ### Step 3: Connect Your Forms Pick the mode that matches your setup: **Elementor Pro** 1. Edit your form widget. 2. Open **Actions After Submit**. 3. Add **Static Forms** to the actions list. Submissions are now sent server-to-server to Static Forms every time the form is submitted. **Contact Form 7** 1. In **Settings → Static Forms**, tick **"Forward Contact Form 7 submissions"**. 2. Every CF7 form on your site now forwards to Static Forms automatically. 3. To opt a single form out, add `staticforms: off` to that form's **Additional Settings** tab. **The `[staticforms]` Shortcode (no form plugin needed)** If you don't use a page builder or CF7, the plugin ships a ready-made contact form. Drop this into any page or post: ```text [staticforms] ``` Customize the fields and the post-submit redirect: ```text [staticforms fields="name,email,message" redirect="https://yoursite.com/thank-you"] ``` ### Why This Is Still Platform-Independent This is the key point: **the plugin posts to the same endpoint your static HTML would use.** When you migrate off WordPress later, you swap the form `action` on the new site to that endpoint and submissions keep flowing to the same inbox — zero downtime, zero reconfiguration. The plugin is a convenience layer, not a dependency. ## Approach 2: Plain HTML in a Custom HTML Block (Works Everywhere) If you want maximum portability — or you're on a WordPress setup without Elementor or CF7 — skip the plugin and paste a plain HTML form directly into a page. This is the most platform-independent option because the form has *no* WordPress dependency at all. The same markup works unchanged on a static site. ### Step 1: Add a Custom HTML Block 1. Go to **Pages → Add New** (or edit an existing page). 2. Click **+** to add a block and choose **Custom HTML**. 3. Paste the form code below. ### Step 2: Paste Your Form ```html
``` **Replace `YOUR_API_KEY_HERE`** with your actual API key from the dashboard. ### Step 3: Understand the Configuration Fields - **`action`** — points to the Static Forms endpoint that handles every submission. - **`apiKey`** — authenticates the form with your account. - **`replyTo="@"`** — tells Static Forms to use the submitter's email as the reply-to address, so you can reply directly from your inbox. - **`honeypot`** — a hidden field that traps spam bots. Real users never see or fill it. That's it — this form is fully portable. Copy it to a `.html` file on GitHub Pages, an Astro component, or a Next.js page and it behaves identically. ### Optional: AJAX Submission Without Leaving the Page For a smoother experience, submit via JavaScript and show an inline confirmation instead of a page redirect: ```html
``` ## Approach 3: Other Form Plugins Many WordPress form plugins (Gravity Forms, WS Form, Fluent Forms, and others) let you set a **custom form action** or add a **webhook/POST integration**. Point that action at: ```text https://api.staticforms.dev/submit ``` …and include your `apiKey` as a hidden field or POST parameter. Your existing plugin handles the UI; Static Forms handles delivery and storage. This keeps the submission pipeline portable even while you keep your favorite form builder. ## Adding Spam Protection The hidden honeypot field stops most bots, but for higher-traffic sites you can layer on CAPTCHA. Static Forms supports reCAPTCHA v2 on every plan, plus hCaptcha, Cloudflare Turnstile, ALTCHA and reCAPTCHA v3 from the Starter plan up ($6/month, or $4/month billed annually). For example, to add Google reCAPTCHA, load the script in your page and add the widget: ```html
``` Then configure the matching secret key in your [Static Forms CAPTCHA settings](https://www.staticforms.dev/app/forms). See our [understanding reCAPTCHA guide](/blog/understanding-recaptcha) and [Cloudflare Turnstile best practices](/blog/cloudflare-turnstile-best-practices) for details. ## Going Beyond Email: Webhooks and Integrations Because submissions flow through Static Forms rather than WordPress, you get integrations that don't depend on WordPress plugins: - **Webhooks** to Slack, Telegram, Discord, your CRM, or any custom endpoint (from the Starter plan up) - **Google Sheets** for a live spreadsheet of submissions (Pro and Agency only, alongside Notion, Airtable and Mailchimp) - **File uploads** with persistent storage and download from your inbox (from the Starter plan up) - **Custom fields** by prefixing a field name with `$`, e.g. `name="$company"` Configure these once in your [dashboard](https://www.staticforms.dev/app/forms) and they apply no matter which platform the form lives on. Learn more in our [Slack integration guide](/blog/how-to-send-form-submissions-to-slack) and [Google Sheets integration guide](/blog/google-sheets-integration-guide). ## The Migration Test Here's the real proof that your forms are platform-independent. Suppose you decide to move from WordPress to a static site: 1. Export or rebuild your pages on the new platform (Astro, Next.js, plain HTML — anything). 2. Copy your form markup over, keeping the same `action` and `apiKey`. 3. Deploy. That's the entire migration as far as forms are concerned. No rewiring notifications, no exporting entries from a plugin database, no re-creating spam rules. Submissions land in the same inbox they always did. Try doing that with a form locked inside a WordPress plugin. ## Troubleshooting **Not receiving submissions?** - ✅ Confirm the API key in the form matches your dashboard - ✅ Check your spam/junk folder - ✅ Verify your account email is confirmed in Static Forms **Form posts but shows an error?** - ✅ Open the browser console and check for JavaScript errors - ✅ Confirm the endpoint URL is exactly `https://api.staticforms.dev/submit` - ✅ When submitting via JavaScript, include the `Content-Type: application/json` header **Using the plugin and the key is exposed in page source?** - ✅ Make sure you configured the key under **Settings → Static Forms**, not as a visible field. The plugin forwards server-to-server precisely so the key stays off the page. ## Conclusion WordPress is a great place to build pages — but your forms shouldn't be hostage to it. By posting submissions to a portable endpoint instead of relying on PHP, plugin databases, and host mail, you get forms that are reliable today and portable tomorrow. Whether you use the official plugin for Elementor and Contact Form 7, paste plain HTML into a Custom HTML block, or wire up your existing form builder, the result is the same: one inbox, one endpoint, zero lock-in. Ready to build forms that outlive your platform? [Sign up for Static Forms](https://www.staticforms.dev) and get started free. For more integration walkthroughs, see our guides for [GitHub Pages](/blog/github-pages-contact-form-tutorial), [Astro](/blog/astro-contact-form-tutorial), and [Next.js](/blog/using-staticforms-with-nextjs). --- ## HTML Tag Button Source: https://www.staticforms.dev/blog/html-tag-button Date: 2026-06-03T09:58:02.403Z Author: Static Forms Team You build a quick contact form, add a "Close" button to a modal, click it, and the page reloads. That bug usually isn't React, Vue, Next.js, or your modal logic. It's the HTML button element doing exactly what the platform says it should do. The html tag button looks simple until it sits inside a real form, picks up framework event handlers, and becomes the only thing standing between the user and a hosted form backend. That's where small details matter. A missing `type`, a disabled state that never clears, or a button rendered as a styled `
` can turn a straightforward UI into a support ticket. ## Why Your Button Might Be Submitting Forms By Itself A lot of button bugs start with the same setup. You've got a form, maybe a newsletter signup or contact page on a static site. Inside that form, you add a secondary button for "Open help", "Close", "Preview", or "Add another field". Then you click it and the browser submits the form. That behavior surprises people because the UI looks like "just a button." In HTML, it isn't. The ` ``` In React, Vue, Astro, Blade, or plain HTML, the browser still decides what an untyped button does. Frameworks can hide the markup, but they do not change the platform rule. ### Use name and value when the clicked button changes backend behavior A submit button can send its own `name` and `value` with the form. That is useful when one form supports more than one action and the backend needs to know which path to run. ```html
``` This pattern holds up well on static sites too. A form service can inspect `intent=draft` versus `intent=publish`, and your serverless function or webhook can branch without extra hidden inputs or client-side state. ### Disabled changes both UX and form behavior `disabled` prevents clicks, keyboard activation, and normal form participation. ```html ``` That is useful for duplicate-submit protection, but it has a cost. A disabled button will not submit, and its value will not be included in the request. If your validation code disables the button and never restores it, the form appears broken even though the markup is fine. This shows up often with static form handlers because there may be no application server logs to explain why nothing was posted. ### Use form when the button lives outside the form element Buttons can be associated with a form by `id` even when they sit elsewhere in the DOM. This solves a common layout problem in sticky action bars, card footers, drawers, and modal shells. ```html
``` This is one of those native HTML features that gets overlooked because many developers reach for JavaScript first. In component-based apps, it can simplify markup and reduce event plumbing. ### Button-level form overrides can be powerful Submit buttons can override the parent form with attributes such as: - **`formaction`** to send the submission to a different URL - **`formmethod`** to change the HTTP method - **`formenctype`** to change how the payload is encoded - **`formtarget`** to choose where the response opens - **`formnovalidate`** to skip built-in validation for that button only ```html
``` Used carefully, these attributes let one form support multiple backend flows. For example, a static site might send the normal submit button to a service endpoint, while a preview button posts to a serverless route that returns rendered output. Used carelessly, they make form behavior hard to reason about. If each button submits somewhere different, document it clearly and test the network requests, not just the UI state. ## Button vs Input vs Anchor A Semantic Showdown A lot of button bugs start as semantic bugs. The UI looks right, QA clicks it once, and nobody notices the mismatch until keyboard behavior, screen reader output, or form handling breaks in production. ` ``` ` ``` Or: ```html ``` ```css .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; white-space: nowrap; border: 0; clip: rect(0, 0, 0, 0); } ``` > Don't hide meaningful button text with `display: none`. That removes it from the accessibility tree. ### Use ARIA for state, not as a substitute for semantics ARIA helps when the button controls stateful UI. It doesn't turn the wrong element into the right one. Use `aria-pressed` for toggle buttons: ```html ``` Use `aria-expanded` with `aria-controls` when a button shows or hides content: ```html ``` ### Keep labels honest "Submit" is acceptable. "Go" is usually weak. "Request demo", "Send message", and "Download invoice" are better because users know what happens next. That matters even more on forms. The button is often the final confirmation that the user's data is about to leave the page. ## JavaScript Interaction and Event Handling Buttons are where your UI usually meets state. Loading, validation, disabling, analytics, dialog opening, optimistic updates. Most of the messy parts happen right after a click. ![A six-step infographic illustrating the process of handling user interactions with JavaScript buttons in web development.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/f050ca35-88c7-4bfa-9eb5-6f82c15af39c/html-tag-button-javascript-event.jpg) ### Click handling without fighting the browser For a plain action button, the pattern is simple: ```html ``` ```js const copyButton = document.getElementById('copy-url'); copyButton.addEventListener('click', async () => { await navigator.clipboard.writeText(window.location.href); copyButton.textContent = 'Copied'; }); ``` For submit buttons, decide whether you want native form submission or JavaScript-controlled submission. Don't mix them casually. If you need custom validation or async logic first, intercept the submit event instead of only listening for button clicks: ```html
``` ```js const form = document.getElementById('contact-form'); const submitButton = document.getElementById('submit-btn'); form.addEventListener('submit', async (event) => { event.preventDefault(); submitButton.disabled = true; submitButton.textContent = 'Sending...'; try { const formData = new FormData(form); const response = await fetch(form.action, { method: form.method, body: formData }); if (!response.ok) { throw new Error('Request failed'); } submitButton.textContent = 'Sent'; form.reset(); } catch (error) { submitButton.disabled = false; submitButton.textContent = 'Send'; } }); ``` If you want a deeper walkthrough of JavaScript-powered form submissions, this guide on [HTML forms with JavaScript](https://www.staticforms.dev/blog/html-forms-with-javascript) is a practical companion. ### Prevent duplicate submissions The most common button-related JavaScript bug after accidental submits is double submits. A user clicks twice because nothing changed visually after the first click. Fix that with immediate state feedback: - **Disable early:** Set `disabled = true` before the async call starts. - **Change the label:** "Send" becoming "Sending..." confirms the click landed. - **Recover on failure:** Re-enable on errors, validation failures, and network interruptions. ### Enable buttons from real state, not vibes For validation-driven UIs, tie `disabled` to actual field validity or business rules. ```js const emailInput = document.querySelector('input[name="email"]'); const saveButton = document.getElementById('save-btn'); function syncButtonState() { saveButton.disabled = !emailInput.checkValidity(); } emailInput.addEventListener('input', syncButtonState); syncButtonState(); ``` That's better than enabling the button on any input event and hoping the submit handler catches the rest. ## Common Pitfalls and Browser Quirks The html tag button isn't hard. It's just easy to underestimate. Most bugs come from a few repeat offenders. ![An infographic illustrating six common web development pitfalls and browser quirks when working with HTML buttons.](https://cdnimg.co/ddd8d9fb-9b81-4eed-89ed-45a76c40be86/9570cdd7-ea54-467d-89fb-9c04ff56e2ce/html-tag-button-common-pitfalls.jpg) ### The bugs I see most often - **Accidental submission:** A button inside a form omits `type` and submits during unrelated UI actions. - **Broken hosted forms:** For static-site form builders, the button is often the only user action that triggers the backend, so a misconfigured `type`, disabled state, or hidden control can break the flow without useful server-side logs, as noted in Lenovo's overview of [HTML button behavior](https://www.lenovo.com/us/en/glossary/html-button-tag/). - **Reset misuse:** `type="reset"` clears more than users expect. It's rarely the right default for production forms. - **Interactive nesting:** Putting a button inside a link, or vice versa, creates confusing click behavior and accessibility issues. - **Event bubbling surprises:** Parent click handlers fire when you only meant to handle the button itself. ### Event bubbling can make good markup feel broken A very normal pattern is a clickable card with a nested button. Then the user clicks "Delete" and the card's outer click handler also runs. ```html

Draft Post

``` ```js document.getElementById('card').addEventListener('click', () => { console.log('Open details'); }); document.getElementById('delete-btn').addEventListener('click', (event) => { event.stopPropagation(); console.log('Delete item'); }); ``` You don't always need `stopPropagation()`, but nested interactive patterns are where bubbling gets expensive. > Test the button by clicking its text, its icon, and any surrounding padded area. Different click targets can expose event bugs fast. ### Browser styling still needs checking Even with modern resets, buttons can differ in font inheritance, inner padding, focus presentation, and appearance defaults. If your design system supports Chrome, Safari, and Firefox, test all three before you call the component done. A common failure path is applying a global class built for anchors to buttons and assuming they'll render identically. They usually won't. Buttons have their own default behavior, box model assumptions, and disabled states. ### Value debugging trips people up If you expect a button's `name` and `value` to show up in submitted data, make sure the user manually clicked that submit button. A form submitted through script or Enter-key behavior may not include the same button intent you expected. That's one of those bugs that looks like "backend inconsistency" but starts in the markup. ## Using Buttons in Frameworks and with Form Backends Frameworks don't change what a button is. They mostly make it easier to hide the native behavior under abstractions. ### React and Next.js patterns In React, always be explicit with `type`. That matters even for small component libraries because a generic `Button` component often ends up rendered inside forms later. ```jsx import { useState } from 'react'; export default function ContactForm() { const [sending, setSending] = useState(false); return (
setSending(true)} >
); } ``` In Next.js app router projects, this same pattern still applies. Server Actions are useful in some cases, but plain HTML form posts are still a very practical option for static and mostly-static sites. ### Vue keeps the same rules Vue's event syntax is different. The button semantics are not. ```vue