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.
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:
curl -i -X POST -d "message=hello" https://your-project.vercel.app/contactHTTP/2 405
server: Vercel
content-disposition: inline
x-vercel-cache: HIT
content-length: 0The 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 |
- The form posts to a path on your Vercel deployment.
- Vercel looks for a function that handles that path.
- If you wrote one, it runs. In a static build there is none, so Vercel answers 405 with an empty body.
- With the action pointing at Ikiform, the post never reaches Vercel.
- Ikiform stores the message, emails you and redirects the browser back to a static page on your Vercel site.
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:
<form method="post">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button>Send</button>
</form><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
requiredattributes 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:
VITE_IKIFORM_FORM_ID Production frm_xxxxxxxxxxxxxxxx
VITE_IKIFORM_FORM_ID Preview frm_yyyyyyyyyyyyyyyyVite 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:
<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 with400 Bad Requestbefore 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.
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:
{
"$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.
localhost tests, so they count toward your plan like any live submission.Check it works
- Run the
curlabove against your production URL before the fix, and confirm the405. After the fix, the form'sactionin the deployed HTML starts withhttps://ikiform.com/f/. - Open a preview deployment, submit, and confirm the message arrives in the preview form, not the production one.
- Submit on your production domain and check the
buildfield readsproduction. - 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
emailfield. - 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
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.
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.
Better not. Give previews their own form id through a Preview environment variable, so test messages don't reach your inbox or your webhooks.
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.
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.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.