splitforms.com
← Back to the journal

Form Submission 405 Error: Why Static Hosts Reject POST (2026 Fix)

Form returns 405 Method Not Allowed on Netlify, Vercel, or GitHub Pages? Static hosts can't run code behind a POST. Why it happens, how to confirm it…

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5 per month.
Short answer
A 405 on form submission means the URL exists but only serves GET — which is every URL on a static host. Netlify, Vercel, GitHub Pages, and Cloudflare Pages serve prerendered files from a CDN; there is no server code at /contact to receive a POST body. Fix it by changing the form's action to an endpoint that actually runs code: a hosted form backend (fastest, zero infrastructure), a serverless function, or the host's built-in forms feature where one exists.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

What 405 Method Not Allowed actually means

HTTP status codes in the 4xx range mean "your request was understood and rejected." The 405 is unusually specific: the URL exists, but not for the HTTP method you used. Compare:

  • 404 Not Found — nothing lives at this URL for any method.
  • 405 Method Not Allowed — something lives here, but it only answers (typically) GET and HEAD. Your POST was refused before any application logic ran.

Per the HTTP spec (RFC 9110 §15.5.6), a 405 response should include an Allow header listing the methods the resource supports. Static hosts generally do:

HTTP/2 405
allow: GET, HEAD
content-type: text/html

allow: GET, HEAD is the smoking gun. It says: this URL is a file, not a program. You can read it; you cannot send it anything. If you're getting a different status entirely, start with the broader contact form debugging guide — this post is specifically about the 405.

Why static hosts can't accept POST — by design

A static host is, at its core, a CDN in front of a folder of files produced by your build. When a request for /contact arrives, the edge node looks up contact/index.html and streams the bytes back. That lookup-and-stream operation is the entire request lifecycle. There is:

  • No process that parses a request body — the body of your POST is discarded unread.
  • No database or mailer to do anything with the data even if it were parsed.
  • No application code at all between the TLS termination and the file system.

This isn't a limitation to be patched; it's the trade that makes static hosting fast, cheap, and effectively unhackable. The cost is that anything dynamic — and accepting a form submission is dynamic — must happen somewhere else: in a function the host runs on demand, or on a third-party server you point your form at.

This is why the same HTML that worked on your old PHP shared host (where contact.php was a program) breaks the moment you migrate to Netlify or Pages. The markup didn't break — the execution environment behind the URL disappeared. The full landscape of options is covered in static site contact forms and working contact forms for JAMstack.

Host-by-host: what each platform does with your POST

GitHub Pages

Purely static, no escape hatch. POST to any github.io URL returns 405. There is no functions product and no forms feature — an external endpoint is the only option. (Setup guide: add a contact form to GitHub Pages.)

Netlify

Static paths 404/405 on POST, but Netlify has two escape hatches: Netlify Forms (add data-netlify="true" so the build scans and registers the form — with a hidden form-name input if your form renders via JavaScript) and Netlify Functions. Gotchas: forms injected client-side after build are never detected, and Netlify Forms remains tied to Netlify hosting even though current credit plans include unlimited submissions. Details and the no-functions alternative: Netlify contact form without functions.

Vercel

No built-in form handling. Static output 405s on POST. The platform answer is a serverless function — in Next.js, a route handler:

// app/api/contact/route.ts
export async function POST(req: Request) {
  const data = await req.formData();
  // ...validate, store, send email yourself
  return Response.json({ ok: true });
}

Note the function only receives the data — you still have to build validation, spam filtering, and email sending (the genuinely hard part). And if you build with output: "export", API routes don't exist at all. The trade-offs are covered in server actions vs form backend.

Cloudflare Pages / S3 + CloudFront

Same story: static assets are GET-only. Cloudflare offers Pages Functions; S3 offers nothing (S3 returns 405 Method Not Allowed with an XML error body for POST to a website endpoint). External endpoint or function, your pick.

Confirm the diagnosis with curl

Take your HTML and JavaScript out of the equation. From a terminal:

# 1. Does the page itself accept POST? (Expect 405 on a static host)
curl -i -X POST -d "name=test&email=t@example.com" https://yoursite.com/contact

# 2. What methods ARE allowed? Read the Allow header in the response.

# 3. Does your intended endpoint accept POST? (Expect 200)
curl -i -X POST \
  -d "access_key=YOUR_ACCESS_KEY&email=t@example.com&message=hello" \
  https://splitforms.com/api/submit

If (1) returns 405 and (3) returns 200, the entire fix is one attribute in your HTML — the action. No build changes, no redeploy logic, nothing else. If (3) fails too, the problem is the payload (missing key, wrong Content-Type); read the response body, which names the issue.

One more 405 flavor worth knowing: redirects can convert your POST into a GET-then-405 or strip the body. If your form posts to http:// and the host 301s to https://, or posts to /submit which redirects to /submit/, browsers re-issue the request in ways that lose the method or body. Check the Network tab for a 301/308 before the 405, and always use the exact, final, https URL in action.

Unexpected token '<', "<!DOCTYPE "... is not valid JSON

This is how a 405 (or 404) shows up when the form submits with fetch(): the host answered with an HTML error page, and res.json() choked on its first character, the < of <!DOCTYPE html>. The JSON error hides the real status, so check the Network tab for the 405 underneath. Each browser words the parse error differently:

Calling res.json() on an HTML page, word for word
Browser and sourceExact message
Chrome 154res.json() and JSON.parse()Tested October 2, 2026SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Safari 26.6.2res.json()Tested October 2, 2026SyntaxError: The string did not match the expected pattern.
Safari 26.6.2JSON.parse()Tested October 2, 2026SyntaxError: JSON Parse error: Unrecognized token '<'
FirefoxFirefox source: ErrorNumbers.msg (JSMSG_JSON_BAD_PARSE)SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data

Tested in Chrome 154.0.8037.97 and Safari 26.6.2 on macOS 26.6.2, on . Firefox wasn't part of that run, so Firefox wording is quoted from Firefox's own source or MDN, linked in each row.

Two changes make the real error visible. Check res.ok before parsing, and only call res.json() when the response says it is JSON:

const res = await fetch(form.action, { method: 'POST', body: new FormData(form) });
const isJson = (res.headers.get('content-type') || '').includes('application/json');

if (!res.ok) {
  throw new Error('Request failed (' + res.status + ')'); // e.g. 405 from a static host
}
const data = isJson ? await res.json() : null;

Then fix the 405 itself with one of the fixes below. More on reading API error bodies in how to send form data to an API.

The three fixes, ranked

1. Point the form at a hosted form endpoint (fastest)

Change one attribute and the 405 is gone, on any host:

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

splitforms accepts the POST your host can't: submissions hit your dashboard, spam is filtered, owner email delivery works on every plan, and Pro can fan out to Slack or any signed webhook. Free for 500 submissions total, then Pro at $5/mo for 5,000 a month with webhooks. No build config, works the same on every static host. (Compare options in best form backends or against Formspree.)

2. Use your host's native feature (if it has one)

Netlify Forms works for simple cases if you accept build-time detection and host coupling. Current credit plans include unlimited form submissions. Nothing comparable is built into Vercel, GitHub Pages, or S3.

3. Write a serverless function (most control, most work)

Right when you need custom logic — writing to your own database, multi-step workflows. You own validation, spam defense, email deliverability (see why form emails go to spam), and uptime. For a contact form, that's a lot of infrastructure for one <form> tag.

Verify the fix end to end

  1. Deploy the updated action and hard-refresh the live page (CDNs cache HTML — make sure you're testing the new markup).
  2. Submit a real test entry from the deployed site, not localhost.
  3. Network tab: the POST should return 200, no redirect chain in front of it.
  4. Confirm the email notification arrives and the submission shows in the dashboard.
  5. Run the rest of the pre-launch form test checklist — a form that returns 200 can still fail on the email leg.

Other form problems and fixes

Still stuck? Test your endpoint with our free Form Action Tester or contact splitforms support.

FAQ

What does 405 Method Not Allowed mean for a form?

The URL in your form's action exists, but the server refuses the HTTP method you used — almost always POST. It's different from a 404: the resource is there, it just only answers GET (and usually HEAD). On static hosting this is expected behavior, because there is no server-side code behind the page to receive a POST body. The response often includes an Allow: GET, HEAD header telling you exactly which methods the URL supports.

Why does my form return 405 on Netlify?

Because your form is POSTing to a static path with no handler behind it. Netlify's CDN only serves files for GET. Netlify does have a built-in forms feature, but it requires opting in — adding data-netlify="true" (or the netlify attribute) to the form so the build system detects it at deploy time, plus a hidden form-name input for JavaScript-rendered forms. If those attributes are missing, or the form is injected client-side after build, Netlify never registers it and POST returns a 404 or 405. Alternatively, point the form's action at an external form endpoint and skip build-time detection entirely.

Why does my form return 405 on Vercel?

Vercel serves your static output through its CDN, and static assets only respond to GET. A POST to /contact (a prerendered HTML page) gets a 405 because nothing executes server-side at that path. Vercel has no built-in form handling at all — your options are a serverless function (an API route in Next.js, or a file in /api for other frameworks) or an external form backend. If you're on Next.js with output: "export", API routes don't exist in the build, so an external endpoint is the only option without changing your deployment model.

Can GitHub Pages handle form submissions?

No. GitHub Pages is purely static file hosting — there is no mechanism to run server code, no functions product, and no built-in forms feature. Any POST to a github.io site (or a custom domain on Pages) fails; GitHub's servers respond with 405 Method Not Allowed. The only way to have a working form on GitHub Pages is to POST to an external endpoint: a form backend service, or your own API hosted elsewhere.

How do I confirm a 405 is the host and not my code?

Test the endpoint directly with curl, bypassing your HTML and JavaScript entirely: curl -i -X POST -d "name=test" https://yoursite.com/contact. If the response is HTTP/2 405 with an Allow: GET header, the host is rejecting the method and no frontend change can fix it. Then test your form backend's URL the same way — curl -i -X POST -d "access_key=...&email=t@example.com" against the real endpoint should return 200, proving the fix is just changing the action attribute.

Will changing my form's method to GET fix the 405?

It makes the error disappear but it is the wrong fix. GET puts the submission into the URL's query string, which gets logged by servers, proxies, browser history, and analytics; it has practical length limits (~2,000 characters safely); and your static host still does nothing with the data — you'd just reload the page with the message in the URL. Form submissions carrying user data should be POST to an endpoint that actually processes them. Fix the destination, not the method.

What's the fastest fix for a 405 on a static site?

Point the form at a hosted form endpoint instead of your own domain. With splitforms: set action="https://splitforms.com/api/submit" method="POST", add your access_key as a hidden input, and deploy — no functions, no build configuration, no server. The endpoint accepts cross-origin posts from any site, emails you the submission, and shows it in a dashboard. Works identically on GitHub Pages, Netlify, Vercel, Cloudflare Pages, and S3. Free for 500 submissions total.

Skip the fix debugging — switch the action to https://splitforms.com/api/submit and it just works. Get a free access key.

Related: CORS errors on form submission, HTML form to email without PHP, JAMstack form backend, and the splitforms API reference.

Related articles

More practical guidance from guides.

Browse the journal →
Guides

WPForms Not Sending Email? 6 Fixes That Work (2026)

WPForms notifications not arriving? Find the wp_mail cause in 2 minutes, then fix it with si…

7 min readRead →
Guides

Ninja Forms Not Sending Email? 8 Fixes for WordPress

Ninja Forms notifications not arriving? Learn why wp_mail fails, how to check email actions…

7 min readRead →
Guides

Netlify Form Not Working? 7 Fixes for Missing Submissions

Netlify form not showing submissions or returning 404? Check the 7 causes, from missing data…

7 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe