Gatsby Functions were one of the quiet conveniences of Gatsby Cloud: drop a file in src/api/, deploy, and you had an endpoint. With Gatsby Cloud gone, that convenience left with it, and the piece of the site that breaks first is almost always the contact form. We see the same failure on nearly every rescue engagement: the marketing pages are fine, the build is fine, and the "Get in touch" form has been silently posting into a 404 for months.
This tutorial covers how to put the dynamic parts of a Gatsby 5 site back on solid ground after Gatsby Cloud — forms, small API endpoints, spam control, and the delivery checks that stop a silent failure from lasting a quarter.
1. First, find out whether your form is actually delivering
Before you rebuild anything, verify the current state. Open the deployed form in a browser with the network tab open, submit a test entry, and record three things: the request URL, the HTTP status, and whether anything arrives in the inbox.
You can do the same from the terminal:
curl -i -X POST https://www.example.com/api/contact \
-H 'Content-Type: application/json' \
-d '{"name":"Delivery test","email":"you@example.com","message":"ping"}'
Three outcomes are common on a post–Gatsby Cloud site:
- 404 or 405 — the
src/api/route was never rebuilt on the new host. The form has been dead since the migration. - 200, but nothing arrives — the endpoint exists and swallows errors from the mail provider. Usually an expired API key.
- 200 and a flood arrives — the endpoint works and has no spam control, so real leads are buried in the noise.
Whatever you build next, keep this curl command. It becomes the smoke test in section 6.
2. Decide whether you need a function at all
Not every Gatsby site needs to run code. Rank the options by how much you will have to maintain:
- A hosted form service. Netlify Forms, Formspree, Basin, or the form endpoint built into your CRM. No code to keep alive, no key rotation, and someone else fights the spam. For a brochure site with one contact form this is the right answer more often than developers like to admit.
- One serverless function you own. Correct when you need to shape the payload, hit a CRM API, enforce your own validation, or keep submissions off a third-party server for privacy or compliance reasons.
- A real backend. Only if submissions feed a workflow with state — quoting, scheduling, authentication.
The rest of this tutorial assumes option 2, because it is the one that requires porting work.
3. Port src/api/ to your host's function runtime
Gatsby Functions used an Express-like (req, res) signature. Modern hosts use the Web Request/Response API instead, so the port is mechanical but not zero-effort.
The old Gatsby function:
// src/api/contact.js (Gatsby Functions — no longer built on most hosts)
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
const { name, email, message } = req.body;
await sendMail({ name, email, message });
return res.status(200).json({ ok: true });
}
The same thing as a Netlify function, which sits outside the Gatsby build in netlify/functions/:
// netlify/functions/contact.mjs
export default async (request) => {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const body = await request.json().catch(() => null);
if (!body) return Response.json({ error: 'Bad request' }, { status: 400 });
const problems = validate(body);
if (problems.length) return Response.json({ errors: problems }, { status: 422 });
await sendMail(body);
return Response.json({ ok: true });
};
export const config = { path: '/api/contact' };
The config.path export is what preserves your existing URL. Keep it identical to the old Gatsby Functions path so bookmarked links, third-party integrations, and your own client code keep working. On Cloudflare Pages the equivalent file is functions/api/contact.js exporting onRequestPost; on Vercel it is api/contact.js. The handler body is the same in all three.
Two details that catch people out:
- Function directories are not part of the Gatsby build. They are not compiled by webpack, they do not see your
gatsby-configaliases, and anything they import must resolve at deploy time. Keep dependencies minimal. - Environment variables are host-level, not
GATSBY_-prefixed. AGATSBY_-prefixed variable is inlined into the client bundle at build time. Your mail provider key must never carry that prefix. Set it in the host's dashboard asMAIL_API_KEYand read it withprocess.env.MAIL_API_KEYinside the function only.
4. Validate on the server, not just in the browser
Client-side validation is a UX affordance. Bots post directly to the endpoint and never see it. Keep the server checks boring and explicit:
function validate({ name, email, message, website }) {
const errors = [];
if (website) errors.push('spam'); // honeypot: must be empty
if (!name || name.length > 120) errors.push('name');
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errors.push('email');
if (!message || message.length < 20) errors.push('message');
if (message && message.length > 5000) errors.push('message_length');
return errors;
}
Layer three cheap defences before you reach for a CAPTCHA:
- A honeypot field — a visually hidden input named something plausible like
website, withtabindex="-1"andautocomplete="off". Any submission that fills it is a bot. This alone removes most low-effort spam. - A timing check — render a timestamp in a hidden field and reject submissions completed in under two or three seconds.
- Rate limiting by IP — most hosts expose the client IP on the request headers; a small KV or Durable Object counter caps a single address to a handful of submissions an hour.
If you still need more, prefer an invisible challenge such as Cloudflare Turnstile over a visible CAPTCHA. Every extra interaction costs you real leads, and on a consulting site a single lost enquiry costs more than a month of spam handling.
5. Make delivery observable
The reason dead forms survive for months is that the failure is invisible: the browser gets a 200 and the developer never sees the mail provider's error. Fix that with two habits.
Send to a mailbox you monitor, and log the provider's response.
const res = await fetch('https://api.mailprovider.example/v1/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAIL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error('mail_send_failed', res.status, await res.text());
return Response.json({ error: 'Could not send' }, { status: 502 });
}
Returning a 502 when the mail send fails matters. If the endpoint returns 200 regardless, the visitor sees a thank-you page, believes they contacted you, and you never learn otherwise. Let the front end show a real error with a fallback mailto link.
Write a second copy somewhere durable. Append each submission to a spreadsheet, a database row, or your CRM as well as sending the email. Mail deliverability fails in ways your code cannot see; a second sink turns a lost lead into a recoverable one.
6. Wire it into CI as a post-deploy smoke test
Put the curl from section 1 into the pipeline so a broken form fails loudly:
- name: Contact form smoke test
run: |
code=$(curl -s -o /tmp/out -w '%{http_code}' -X POST "$SITE_URL/api/contact" \
-H 'Content-Type: application/json' \
-d '{"name":"CI smoke test","email":"ci@example.com","message":"Automated post-deploy check, please ignore."}')
test "$code" = "200" || { echo "form endpoint returned $code"; cat /tmp/out; exit 1; }
Have the function recognise a known test payload and route it to a ci-tests label or a separate address rather than your sales inbox. A monthly synthetic check from an uptime monitor gives you the same protection for sites without a CI pipeline.
7. Keep the static parts static
The point of a Gatsby site is that everything renders at build time. Adding a function should not drag the rest of the page into a dynamic runtime, so keep the boundary tight:
- The form page stays a static page. Only the
fetchon submit crosses the boundary. - No secrets, addresses, or provider keys reach the client bundle. Grep your build output for the key prefix before you ship:
grep -r "sk_live" public/ | head. - Set a
Content-Security-Policyheader with aconnect-srcthat includes your own origin and nothing else you did not intend. - Give the form a working no-JS path — a plain
mailto:link in the markup — so a hydration error never costs you the enquiry.
What to check on your own site this week
- Submit a test through every form on the site and confirm it arrives.
- Grep the repo for
src/api/and confirm each route still exists on the current host. - Confirm no mail or CRM key is prefixed with
GATSBY_. - Add a honeypot field if there is not one.
- Add a post-deploy smoke test so the next silent failure lasts minutes rather than months.
Forms are the highest-value dynamic surface on an otherwise static site, and the one most likely to have quietly broken during a hosting migration. If your Gatsby site has moved hosts in the last two years and nobody has tested the contact form since, assume it is broken until a test email lands in the inbox — and if you would rather someone else did that audit, get in touch.