Building a Contact Form with Netlify Functions: From Local Dev to Production Limits
A practical walkthrough of building a spam-resistant contact form with Netlify Functions, covering local development, deployment, environment secrets, and the timeout/quota limits that shape production decisions.
29 Oct 2026, 22:47 UTC

The problem with static-site contact forms
You've built a static site on Netlify. The marketing page loads instantly, the blog is fast, and the build pipeline is simple. Then the client asks for a contact form that sends email, validates input, and blocks spam—without adding a backend server. Netlify Forms handles basic submissions, but you need custom validation, a honeypot field, and integration with a transactional email API. That's where Netlify Functions fits: a serverless endpoint deployed alongside your site, reachable at /.netlify/functions/<name>, with no infrastructure to manage.
How Netlify Functions works
Place a JavaScript or TypeScript file in your configured functions directory (default netlify/functions/). Each file becomes an HTTP endpoint. The runtime passes an event object containing httpMethod, headers, body, and queryStringParameters, plus a context object with identity and timing info. Your handler returns a response object with statusCode, headers, and body (stringified JSON).
Environment variables defined in the Netlify UI or netlify.toml are available via process.env at runtime. This keeps API keys out of the repository.
Worked example: a POST contact-form function
Create netlify/functions/contact.js with the following logic:
// netlify/functions/contact.js
exports.handler = async (event, context) => {
// Only accept POST
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
let payload;
try {
payload = JSON.parse(event.body || '{}');
} catch {
return { statusCode: 400, body: 'Invalid JSON' };
}
// Honeypot spam check
if (payload.company && payload.company.trim() !== '') {
// Silently succeed to waste bot time
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}
// Basic validation
const { name, email, message } = payload;
if (!name || !email || !message) {
return { statusCode: 422, body: JSON.stringify({ error: 'Missing required fields' }) };
}
// Forward to email service (example using a generic HTTP API)
const apiKey = process.env.EMAIL_API_KEY;
if (!apiKey) {
return { statusCode: 500, body: JSON.stringify({ error: 'Server misconfiguration' }) };
}
const emailResp = await fetch('https://api.emailservice.example/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
body: JSON.stringify({ to: '[contact removed]', subject: `Contact from ${name}`, text: message, replyTo: email })
});
if (!emailResp.ok) {
return { statusCode: 502, body: JSON.stringify({ error: 'Email service unavailable' }) };
}
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
The function validates the request method, parses JSON, checks a honeypot field named company, verifies required fields, and forwards the submission to an email API using a secret stored in EMAIL_API_KEY.
Local development with Netlify CLI
Install the CLI globally or as a dev dependency, then run netlify dev from your project root. This starts a local server (default port 8888) that proxies /.netlify/functions/ requests to your function files, serving your static site simultaneously.
# Terminal, project root
npm i -g netlify-cli # or npx netlify-cli
netlify dev
Test the endpoint with curl:
curl -X POST http://localhost:8888/.netlify/functions/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test User","email":"[contact removed]","message":"Hello","company":""}'
Expect a 200 response with {"ok":true}. Omit the honeypot field or fill it to see the silent success behavior. Note that netlify dev approximates the AWS Lambda environment; cold-start timing, Node.js version, and environment variable resolution may differ from production.
Deploying and verifying in production
Push to your connected Git repository. Netlify detects the netlify/functions/ directory, bundles each file, and deploys them. After deploy, open the Functions tab in the Netlify dashboard to see the generated URL (e.g., https://your-site.netlify.app/.netlify/functions/contact). Run the same curl command against the production URL to confirm identical behavior.
Set EMAIL_API_KEY in Site configuration → Environment variables before deploying. Never commit secrets to the repo.
Trade-offs and limits you'll hit
- Timeout: Default synchronous function timeout is around 10 seconds on most plans. Long-running tasks (large email batches, PDF generation) need Background Functions (suffix
-backgroundin filename) which allow up to 15 minutes but return an immediate202and require a separate result-polling mechanism. - Invocation quota: Plans meter monthly invocations and compute time. Exceeding limits returns
429or disables functions until the next billing cycle. Check current quotas in the Netlify UI—they vary by plan and change over time. - Payload size: Request and response bodies are limited (typically 6 MB). File uploads should use direct-to-storage presigned URLs instead of routing through the function.
- Local vs. production parity:
netlify devdoes not replicate IAM permissions, VPC networking, or exact cold-start latency. Always verify critical paths against a deployed preview branch.
Quick verification checklist
- Run
netlify devand curl the local endpoint; confirm200and expected JSON. - Deploy to a preview branch; curl the production URL from the dashboard.
- In the Netlify UI, review the function's invocation count, duration percentiles, and error rate after a few test submissions.
- Confirm environment variables are set in the UI (not in
netlify.tomlfor secrets).
When to reach for something else
If you need persistent connections (WebSockets), streaming responses, or consistent sub-100ms latency without cold starts, Netlify Functions isn't the right primitive. Consider a dedicated container service or edge workers for those patterns. For typical form handling, webhook receivers, and lightweight API glue, it removes operational overhead while keeping the deployment model simple.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.