# Fix the 405 error on a contact form hosted on Vercel

> A static page on Vercel answers a form POST with 405 Method Not Allowed; here is why, and how to send the form to a backend instead.

Published 2026-10-25 · https://ikiform.com/guides/contact-form-static-site/vercel

Vercel serves your static build as files, and a `POST` to a file gets `405` with an empty body because no function handles that path. Point the form's `action` at your Ikiform endpoint instead, or write a Vercel Function that accepts the post. With Ikiform, set the form id per Vercel environment so previews post to a test form and production posts to the real one.

## Why Vercel answers 405

Vercel routes each request to a static file or to a function. A static site, a Vite build or a Next.js app with `output: "export"` has only files. A `GET` for `/contact` returns the file; a `POST` to the same path has nowhere to go, so Vercel refuses it before any of your code could run:

`Terminal`:

```bash
curl -i -X POST -d "message=hello" https://your-project.vercel.app/contact
```

`Response`:

```http
HTTP/2 405
server: Vercel
content-disposition: inline
x-vercel-cache: HIT
content-length: 0
```

The response has no body, so the browser shows a blank page or its own error screen. Three setups lead here:

| Your code | Why it 405s on Vercel |
| --- | --- |
| `<form method="post">` with no `action` | The form posts to the page it is on, which is a static file |
| `action="/api/contact"` in a Vite or plain HTML project | There is no file in an `api/` folder at the project root, so no function exists for that path |
| A Next.js Server Action or Route Handler in a static export | `output: "export"` doesn't support Server Actions, or Route Handlers that read the request. `next dev` reports the error, and the build has no server to run them |

Diagram: How Vercel decides what to do with a form POST

1. The form posts to a path on your Vercel deployment.
2. Vercel looks for a function that handles that path.
3. If you wrote one, it runs. In a static build there is none, so Vercel answers 405 with an empty body.
4. With the action pointing at Ikiform, the post never reaches Vercel.
5. Ikiform stores the message, emails you and redirects the browser back to a static page on your Vercel site.

Vercel isn't broken here. A static deployment has no code for a POST, and a function is the only way to add one on Vercel's side.

So you either add a function or send the post elsewhere. A Vercel Function in `api/contact.ts` works, but you then need an email API, spam filtering and rate limits, and you maintain them. Sending the post to Ikiform keeps the deployment static. The fix in the HTML is one attribute:

`Before`:

```html
<form method="post">
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>
```

`After`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
```

### If you'd rather write the function

A function is a fair choice when the message has to go somewhere only your code can reach. Count what it takes before you start:

- An email provider account, a verified sending domain and an API key in Vercel's environment variables.
- Validation on the server, because the browser's `required` attributes are a convenience, not a check.
- Spam filtering and rate limits, or a contact form that mails you every bot on the internet.
- Somewhere to keep messages when the email provider is down, and a way to resend them.

A form backend covers that list for you. If you still want your own code in the loop, keep the form posting to Ikiform and receive a signed webhook in a function; [your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md) shows the handler.

## A form id per Vercel environment

Every pull request on Vercel gets a preview deployment. If previews post to your production form, every test message lands in your inbox next to real ones. Create a second Ikiform form for previews, and let Vercel's environment variables choose. In **Settings › Environment Variables**, add the same name twice:

`Vercel environment variables`:

```text
VITE_IKIFORM_FORM_ID   Production   frm_xxxxxxxxxxxxxxxx
VITE_IKIFORM_FORM_ID   Preview      frm_yyyyyyyyyyyyyyyy
```

Vite replaces `%NAME%` placeholders in `index.html` with `VITE_` variables at build time, so a plain HTML form can use them with no JavaScript. `VITE_VERCEL_ENV` is set by Vercel for Vite projects, and records which deployment a message came from:

`index.html`:

```html
<form id="contact" action="https://ikiform.com/f/%VITE_IKIFORM_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="6" minlength="10" required></textarea>

  <input type="hidden" name="build" value="%VITE_VERCEL_ENV%" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send message</button>
</form>
```

- Vercel reads environment variables at build time for a static site. After changing one, redeploy, or the old value stays in the HTML.
- Vite leaves a placeholder as it is when the variable doesn't exist, so a missing id ships as `%VITE_IKIFORM_FORM_ID%` and every post fails with `400 Bad Request` before it reaches the form. The guard below catches that.
- The form id is public either way. Only `VITE_` variables reach the browser, which is why no secret belongs in one.

`src/contact.js`:

```js
const form = document.querySelector("#contact");

if (form && form.action.includes("%VITE_")) {
  console.error("VITE_IKIFORM_FORM_ID is not set for this Vercel environment");
  form.querySelector("button").disabled = true;
}
```

Using Next.js with `output: "export"`? Read `process.env.NEXT_PUBLIC_IKIFORM_FORM_ID` in a client component instead; the [pillar guide](https://ikiform.com/guides/contact-form-static-site.md#form) has a static-export form. If your Next.js app does run on Vercel's servers, a Server Action can post with a secret key: see the [Next.js guide](https://ikiform.com/docs/guides/nextjs.md#server-action).

## Headers and clean URLs

If your site sets a Content-Security-Policy, set it in `vercel.json` and include Ikiform. `cleanUrls` serves `thanks.html` at `/thanks`, which makes a tidier redirect target:

`vercel.json`:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "cleanUrls": true,
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com"
        }
      ]
    }
  ]
}
```

## Previews and allowed domains

Preview URLs such as `your-project-git-branch-team.vercel.app` are not subdomains of `your-project.vercel.app`, so they don't match it under **Allowed domains**. Posts from them land in spam with `origin_mismatch`. That's another reason for a separate preview form: leave its allowed domains empty, or add the preview host you're testing.

> With Vercel Authentication on and the Standard Protection scope, which covers every deployment except production domains, preview URLs sit behind a Vercel login. Only your team can reach them, so a preview form with no allowed domains isn't exposed to the public. Check **Settings › Deployment Protection** before you rely on that. Posts from previews aren't `localhost` tests, so they count toward your plan like any live submission.

## Check it works

1. Run the `curl` above against your production URL before the fix, and confirm the `405`. After the fix, the form's `action` in the deployed HTML starts with `https://ikiform.com/f/`.
2. Open a preview deployment, submit, and confirm the message arrives in the preview form, not the production one.
3. Submit on your production domain and check the `build` field reads `production`.
4. Check the inbox of each notification recipient. Each gets an email with the fields, and **Reply** goes to the submitter when the form has an `email` field.
5. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.

## Gotchas

| Problem | What to do |
| --- | --- |
| Still `405` after changing the form | The deployment you're testing was built before the change, or a JavaScript handler posts to your own path with `fetch`. Search the built files for `/api/contact`. |
| `400 Bad Request` on every post, or `404 not_found` from Ikiform | The form id didn't reach the build, or it's the wrong id for that environment. Check the variable exists for that environment and redeploy. |
| Previews work, production posts land in spam | Your production domain is missing from the production form's **Allowed domains**. Add the custom domain as well as `your-project.vercel.app`. |
| The browser lands on Ikiform's thank-you page instead of yours | `_redirect` is ignored unless its domain is in **Allowed domains** and it is an `https:` URL. Add the domain, or set **Redirect after submit** in the form's settings instead. |

## Questions

### Why do I get 405 Method Not Allowed when submitting a form on Vercel?

The form posts to a path that only has a static file. Vercel serves files for GET requests, and with no function for that path it refuses the POST with 405. Post the form to a form backend or add a Vercel Function.

### Can I use a Next.js Server Action on a static export?

No. Server Actions are not supported with output: export, because there is no server to run them. Post from a client component to a form backend, or deploy without the static export so Vercel runs the action.

### Should preview deployments use the same form as production?

Better not. Give previews their own form id through a Preview environment variable, so test messages don't reach your inbox or your webhooks.

### Do I need vercel.json for a contact form?

No. The form works without it. Add vercel.json only to set a Content-Security-Policy that allows ikiform.com, or to turn on cleanUrls for a tidier thank-you URL.

### Is Ikiform free?

No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.

## More guides

- [Contact form on a static site](https://ikiform.com/guides/contact-form-static-site.md): Add a working contact form to a static site with no server: where the POST goes, spam, replies by email, and code for every host and framework.
- [Netlify](https://ikiform.com/guides/contact-form-static-site/netlify.md): Run a contact form on a Netlify site without Netlify Forms: no build-time detection, JSON posts from React, and a separate form for deploy previews.
- [Cloudflare Pages](https://ikiform.com/guides/contact-form-static-site/cloudflare-pages.md): Add a contact form to Cloudflare Pages without writing a Function: post to a form backend, set headers in _headers and use your own Turnstile keys.
- [GitHub Pages](https://ikiform.com/guides/contact-form-static-site/github-pages.md): GitHub Pages can't run server code, so a form POST gets 405 Not Allowed; post to a form backend instead and get project-site redirects right.
- [Amazon S3](https://ikiform.com/guides/contact-form-static-site/aws-s3.md): S3 website endpoints accept only GET and HEAD over plain HTTP, so post the contact form to a form backend and get redirects right with CloudFront.
- [Portfolio website](https://ikiform.com/guides/contact-form-static-site/portfolio-website.md): A portfolio contact form that asks the questions you'd ask on a first call: project, budget, timing and a link, with no server and replies from your inbox.
