Contact form on a static site

Contact form on a static site6 min read

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.

Preet SutharFounder, Ikiform

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
curl -i -X POST -d "message=hello" https://your-project.vercel.app/contact
Response
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 codeWhy it 405s on Vercel
<form method="post"> with no actionThe form posts to the page it is on, which is a static file
action="/api/contact" in a Vite or plain HTML projectThere 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 exportoutput: "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
How Vercel decides what to do with a form POSTaction points at your Vercel siteaction points at IkiformPOST/contactfrom the formVercelroutingfile orfunction?functionhere?yourfunctionif you wroteone405, emptybodya static filePOST toikiform.comsame formIkiformstores, emailsyouthank-youpagea static fileon Vercelyesno303How Vercel decides what to do with a form POSTaction points at your Vercel siteaction points at IkiformPOST /contactfrom the formVercel routingfile or function?functionhere?your functionif you wrote one405,emptybodya staticfilePOST toikiform.comsame formIkiformstores, emails youthank-you pagea static file onVercelyes303no
  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
<form method="post">
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>
After
<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 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
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
<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
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 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.

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
{
  "$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

ProblemWhat to do
Still 405 after changing the formThe 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 IkiformThe 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 spamYour 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.