Guide

How to add a working contact form to GitHub Pages, Cloudflare Pages or Netlify

These three hosts get lumped together as "static hosting", and for the front half of the job that is fair: you push files, they serve them. For the back half — the part where a form actually has to send something — they are three different products, and the difference decides whether your contact form takes twenty minutes or a weekend.

The short version, before the detail:

  • GitHub Pages has no runtime. A form cannot post to it. Ever.
  • Cloudflare Pages has a function layer, so it can accept a POST — but accepting it is the easy half.
  • Netlify detects forms in your built HTML and handles them for you, which is genuinely convenient and genuinely sticky.

What follows is what each one costs you after launch, with the code for each, and the failure modes that are specific to that host. The last section is a test checklist per platform, because "the form works" on these hosts usually means "the form posted somewhere and something happened", which is not the same claim.

Why the three hosts are not interchangeable

A contact form needs three things done on the server side: something to receive the request, somewhere to put it, and something to tell you it arrived. Static hosting does none of them by definition. Each platform responds to that gap in its own way, and the response defines what you own afterwards.

HostCan receive a POST?If yes, what receives itWhat you own
GitHub Pages No — An external endpoint, and nothing on GitHub's side
Cloudflare Pages Yes, if you add a function A Pages Function you deploy Storage, spam handling, notification email, retries, and the function's own failures
Netlify Yes, if you opt into Netlify Forms Netlify's own handler, created from your markup A host-specific attribute in your HTML, and the migration work if you ever leave
Any of the three plus a hosted endpoint Yes — the endpoint receives it A service built for form posts An account and a dependency; not the server

Note what the table does not say: it does not say any of these is wrong. An internal page with a low-stakes form is fine pointing at nothing much. A business contact form that generates revenue is a different requirement, and the cost of getting it wrong shows up as messages that were never received.

GitHub Pages: no runtime, so the form needs somewhere else to go

GitHub Pages serves a repository through a CDN. There is no application process, no function layer, and no server-side redirects — the only redirects available are HTML meta refreshes and client-side JavaScript, both of which are visible to the visitor as a flash.

What that means in practice is one line of decisions: the form's action must be an absolute URL somewhere else. The site itself stays exactly as it is; you are not adding a backend to GitHub Pages, you are pointing the form away from it.

contact.html — the whole integration
<form action="https://freelyform.com/f/FORM_ID" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required>

  <label for="email">Email</label>
  <input id="email" name="email" type="email"
         autocomplete="email" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <button type="submit">Send</button>
</form>

Two GitHub-specific details are worth knowing before you deploy. First, if you use Jekyll, files and directories starting with an underscore are not published by default; keep the form in a normal page and it will be fine, but a partial placed in _includes and referenced from a page that renders server-side is a common source of "where did my form go". Second, GitHub Pages builds your site from a branch or an Actions workflow, and either way the output is a folder of files — which means the form you test locally and the form that ships can differ if a plugin rewrites the markup. View source on the deployed page once and confirm the action URL is there.

Deployment cache is the other trap. Pages caches aggressively; after changing the endpoint, hard-refresh and check the deployed HTML rather than trusting your local preview.

Cloudflare Pages: a function is available, and usually the wrong tool

Cloudflare Pages serves static assets and, if you add a functions directory, runs code at the edge on requests that match. That is real server-side capability: you can accept a POST, parse the body, and answer with JSON.

The question is whether you should. A Pages Function that accepts a contact form is about thirty lines to start, and then it needs everything a form backend has:

  • Where the submission is stored (D1, KV, R2, or an external database) and how it is queried later.
  • How notification email is sent — which means an email API key, a verified sending domain, and an error path for when the provider rejects a message.
  • Spam handling. A public endpoint gets junk within hours; a function with no filtering is a public inbox for whoever finds it.
  • Rate limiting, so one script cannot fill your storage.
  • Retries, because an email API call that fails at 2am should not lose the message.

None of that is hard, and if you already run Workers for other reasons, adding a form handler is a reasonable afternoon. But it is a permanent piece of infrastructure with an on-call surface, and the failure mode is silent: the function returns 200, the email API returns 200, and the message still never reaches an inbox because the sending domain is not authenticated.

Cloudflare Pages does give you two things worth using either way: _redirects for a real HTTP redirect after submit to a thank-you page, and _headers for cache and security headers. Both are files in your publish directory, so they are versioned with the site.

_redirects — a real redirect instead of a meta refresh
# after the endpoint answers, send the visitor to your own page
/thanks   /thanks.html   200
# if the endpoint redirects for you, this is unnecessary — use _redirect on the form

The compromise that gets you the function's ergonomics without its maintenance is to let the hosted endpoint do the receiving and use the platform only for what it is good at: serving the page, and serving the thank-you page the endpoint redirects to.

Netlify: the platform will handle the form, if it can see it

Netlify's form handling is the slickest of the three: add an attribute to the form, deploy, and submissions appear in the Netlify dashboard addressed to your account email. No endpoint, no key, no code.

Netlify form detection
<form name="contact" method="POST" data-netlify="true">
  <input type="hidden" name="form-name" value="contact">
  <label for="name">Name</label>
  <input id="name" name="name" required>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

The hidden form-name field matters more than it looks: it is how a POST is matched to the form Netlify registered at build time. Templates that render a contact form on every page usually need it added by hand, and a form that posts without it fails with a generic error that reads like a validation problem.

Three constraints define the trade:

Detection happens at build time. Netlify reads the deployed HTML. A form rendered by client-side JavaScript after load is invisible to it — that is the single most common "Netlify Forms does not work" cause, and it is a consequence of the design rather than a bug. The same is true of forms added by a framework that hydrates them on the client.

The intake belongs to the platform. Submissions live in Netlify's dashboard under its plan limits. Exporting is possible; running your own query against them is not, because there is no API that behaves like your own database. If the form feeds anything downstream, you are bridging from Netlify's store to yours.

Leaving means rebuilding. The data-netlify attribute and the form-name field are host-specific. Moving to another host, or to a hosted endpoint, means changing the markup and re-testing, because the thing that made the form work was the host's detection, not your HTML.

None of this makes Netlify Forms a bad choice. For a marketing site on Netlify whose forms are contact and newsletter signup, it is the least work of anything in this article. The honest framing is that you are choosing a house format, and house formats are fine until you want to leave the house.

The path that works on all three: a hosted endpoint

If the site might move, or if the form is more than a contact form, the portable option is the one where the three hosts are interchangeable: standard HTML pointing at an endpoint that is not part of the host.

  • GitHub Pages: zero configuration beyond the absolute action URL. The repo stays a repo.
  • Cloudflare Pages: no function, no bindings. Optionally keep the endpoint as a path behind the same domain with a route rule, if you want one hostname for the visitor.
  • Netlify: no detection attributes. If you want Netlify's redirects, they still work — they are independent of form handling.

The markup is identical in all three cases, which is the property that makes a site portable: the host serves bytes, the endpoint accepts the post, and neither knows much about the other. Change hosts and the form keeps working; change endpoints and the site keeps working.

What breaks on each host, specifically

SymptomHostCause
POST returns 405 or a 404 pageGitHub PagesThe action points at the site itself. There is no handler there.
Form submits, then shows a raw error pageAnyThe endpoint rejected the payload (missing field, bad type) and the browser rendered its response.
Netlify dashboard shows no formNetlifyThe form is rendered client-side, or the detection attribute is missing from the built HTML.
Netlify form listed, POST failsNetlifyMissing hidden form-name field, or a field name that collides with a reserved one.
Function runs, nothing arrivesCloudflare PagesSending domain not authenticated (SPF/DKIM), or the email API key is absent in the environment.
Works locally, fails on the domainAnyLocal preview has no real endpoint; or the endpoint's origin allowlist does not include the deployed domain.
Duplicate submissionsAnyNo disabled state on the button while the request is in flight, and no idempotency key at the endpoint.

Spam, given that none of these hosts can filter it

Static hosting cannot inspect a submission it never receives. Whichever route you take, the filtering has to happen at the thing that receives the request — the endpoint, the function, or the platform's own service.

What works, in order of cost:

  1. A honeypot field. One hidden input that a person cannot see and cannot tab to; bots fill every input they find. Costs nothing, stops the crude majority, and produces a false positive only when a password manager autofills a field it was told to ignore.
  2. A time trap. A hidden timestamp written when the page renders, checked at the endpoint. A submission that arrives in under two seconds was not typed by a human. No captcha, no friction, no accessibility cost.
  3. Rate limits per form and per address. Twenty posts a minute from one client is a script. This is the limit that protects your storage and your sending reputation, not your inbox aesthetics.
  4. Content rules. A payload with nine links, a well-known junk phrase, or a throwaway mail domain is classifiable without a human reading it.
  5. A captcha on genuinely public forms. Turnstile, reCAPTCHA or hCaptcha, on the pages that get traffic from strangers rather than from your own site.

The design detail that matters is what happens when a rule fires. Deleting the submission is the worst option, because a false positive then becomes a lost customer you never hear from. Storing it with a verdict attached — as spam, with the reasons — costs a row and keeps the record inspectable. That is the difference between a filter you can tune and a filter you have to trust.

Testing, per platform, before you tell anyone the form works

Run these against the deployed URL, not the local preview. Each one maps to a failure above.

GitHub Pages

  1. View source on the deployed page and confirm the action is the absolute endpoint URL, not /contact.
  2. Submit with JavaScript disabled, so you test the native path rather than your fetch wrapper.
  3. Submit with a required field empty and read the error the visitor would see.
  4. Confirm the notification email arrived and the delivery record says sent.

Cloudflare Pages

  1. If you deployed a function, request the route with curl and check the status code it actually returns rather than the one your UI implies.
  2. If you use _redirects, confirm the redirect is a real 30x by checking curl -I, not by watching the page change.
  3. Send one submission with a file attached if your form accepts them — multipart is the part most often untested.

Netlify

  1. After deploy, check the Forms tab. No form listed means detection failed and nothing you do in the browser will fix it.
  2. Submit once and confirm the submission appears in the dashboard, then confirm the notification email arrived.
  3. Test the same form on a page that renders it through a template, since that is where form-name goes missing.

Which one to choose

Choose host-detected forms when the site is committed to that host, the forms are simple, and the data does not need to live anywhere else. It is the least work, and "least work" is a legitimate engineering criterion.

Choose a function when the submission has to do something on the way through — write to your own database, call an internal service, enforce logic only you know. Then you are building an integration, and the form is a thin client for it.

Choose a hosted endpoint when what you want is the form to work, be recorded, and stay portable, without owning a handler. That is the case for most contact forms, including the ones on sites that could afford to build the handler and would rather not maintain it.

The one thing worth avoiding is drifting into a choice by accident: a Netlify form added for convenience in 2023 becomes a migration in 2026, and a hand-rolled function added for a weekend becomes infrastructure nobody remembers owning. Decide which of the three you are doing, write it down, and the next person — who might be you — inherits a decision instead of a mystery.

Where to go next

Hosting questions

Can GitHub Pages handle a form POST?

No. GitHub Pages serves files and nothing else — there is no runtime to receive a POST, no function layer, and no server-side redirect. A form on GitHub Pages needs an external endpoint, or a static form service that replaces the endpoint for you.

Does Cloudflare Pages need a function for a contact form?

Not for receiving submissions. A Pages Function can receive the POST, but then you own storage, spam handling, notification email and retries. Unless the handler is genuinely the product, pointing the form at a hosted endpoint is less work and less to maintain.

Why does a Netlify form work without any endpoint at all?

Because Netlify detects forms in the built HTML and creates a handler on its side. The markup gets a host-specific attribute, submissions land in the Netlify dashboard, and the trade is that the intake belongs to that platform — leaving means rebuilding it.

A Netlify form renders but no submissions arrive. What is wrong?

Almost always one of three things: the form was added by JavaScript after the build (Netlify only sees forms present in the built HTML); the detection attribute is missing from the form element; or the page was deployed with a framework adapter that strips the attribute.

Do I need JavaScript for a contact form on any of these hosts?

No. A plain form with an absolute action URL works on all three with scripting disabled. JavaScript only decides whether the visitor stays on the page or is navigated to a thank-you page.

Can I move a form between these hosts later?

Yes, and the cost depends entirely on the markup. Standard HTML with an endpoint URL moves with a one-line edit. Host-detected forms (Netlify Forms) and function-based handlers have to be rebuilt, which is the argument for keeping the markup portable from day one.