Serverless Functions for Static Sites
Every static site eventually needs a small amount of code that runs on a server. A contact form needs somewhere to post to. A newsletter signup needs to call a mailing-list API without exposing its key. A partner portal needs to check who is logged in before serving a page. A pricing page wants to show the reader's currency. None of that justifies abandoning static pages, and none of it needs a traditional server. Serverless and edge functions — small handlers deployed alongside the static files — fill exactly that gap.
This topic covers the functions that static sites actually need: form handling, API proxies, authentication gates and light personalisation, on Cloudflare Workers, Netlify Functions and Vercel Functions. The principle throughout is to keep pages static and cached, and to confine code to the specific routes that need it. It sits inside Production-Ready Deployment & CI/CD Workflows and builds on the hosting choices in Netlify vs Vercel Deployment Strategies and Cloudflare Pages Edge Caching Setup.
Edge Functions Versus Regional Functions
The platforms offer two execution models, and the right one depends on what the code does.
Edge functions — Cloudflare Workers, Netlify Edge Functions, Vercel Edge Functions — run in V8 isolates at hundreds of locations. Cold starts are measured in single-digit milliseconds, they run close to the reader, and they are billed per request. They have tighter limits: CPU time per request in the tens of milliseconds on free plans, a subset of Node.js APIs, and no long-running work. They suit request routing, header manipulation, redirects, lightweight APIs, auth checks and proxies.
Regional functions — Netlify Functions and Vercel Functions on the Node.js runtime, AWS Lambda — run full Node.js in one or a few regions. Cold starts range from 100 ms to over a second, and they sit far from some readers, but they allow longer execution, larger dependencies and native modules. They suit heavy work such as image processing, PDF generation, or anything that talks to a database in a specific region.
| Measured on a contact form endpoint | Cloudflare Worker | Netlify Edge Function | Netlify Function (Node) | Vercel Function (Node) |
|---|---|---|---|---|
| Cold start (p50) | 4 ms | 11 ms | 290 ms | 240 ms |
| Warm response, reader in Frankfurt (p50) | 22 ms | 31 ms | 58 ms (region eu-central) | 64 ms (fra1) |
| Warm response, reader in Sydney (p50) | 26 ms | 38 ms | 312 ms | 330 ms |
| CPU limit per request (default) | 10 ms free / 30 s paid | 50 ms | 10 s | 10–60 s |
For the functions static sites typically need, edge functions are the better default; regional functions are for the exceptions. The comparison is expanded in Netlify Functions vs Cloudflare Workers.
Forms Without a Form Service
The most common function on a static site is a form handler. It receives a POST, validates the fields, blocks spam, stores or forwards the submission and redirects the reader to a thank-you page. A hosted form service does this with no code; a function does it with full control over validation, storage and data location. The progressive-enhancement version works without JavaScript — an ordinary <form method="post" action="/api/contact"> — and a small script can upgrade it to an inline success message.
Spam protection matters more than anything else in the handler. A honeypot field and a minimum time-to-submit check stop most bots; a challenge such as Cloudflare Turnstile stops the rest without puzzles for readers. Handling Form Submissions on a Static Site compares approaches, and Adding a Contact Form with Cloudflare Workers builds one end to end.
Proxying Third-Party APIs
Static pages often want live data: the latest release from GitHub, stock levels, a status summary, search from a hosted index with a private key. Calling those APIs directly from the browser either exposes a secret key or runs into CORS and rate limits. An edge function in between adds the key server-side, restricts which upstream endpoints can be called, caches responses for a sensible period, and shapes the response to exactly what the page needs.
// functions/api/releases.js (Cloudflare Pages Function)
export async function onRequestGet({ env }) {
const cache = caches.default, key = new Request('https://cache.internal/releases');
let res = await cache.match(key);
if (!res) {
const up = await fetch('https://api.github.com/repos/acme/cli/releases?per_page=5', {
headers: { authorization: `Bearer ${env.GH_TOKEN}`, 'user-agent': 'docs-site' } });
const data = (await up.json()).map((r) => ({ tag: r.tag_name, date: r.published_at, url: r.html_url }));
res = new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json', 'cache-control': 'public, max-age=300' } });
await cache.put(key, res.clone());
}
return res;
}
With a five-minute cache, 1.2 million page views a month produced about 9,000 upstream calls, comfortably within GitHub's rate limit. The full pattern, including allow-lists and error handling, is in Proxying Third-Party APIs from an Edge Function.
Gating Static Content Behind Authentication
Partner portals, internal handbooks and paid documentation are often perfect static sites that must not be public. An edge function in front of the static assets can check a session cookie or an identity provider's token on every request and serve the static file only when the check passes — without turning the site into an application. On Cloudflare, Access does this with no code; elsewhere a middleware function verifies a signed JWT from the identity provider. Protecting a Static Site Behind Authentication covers both, including how to keep protected pages out of CDN caches shared between users.
Light Personalisation Without Losing the Cache
Personalisation is where static sites most often go wrong with functions: rendering every page per request to show a greeting throws away the CDN cache for a trivial benefit. Three patterns keep pages cacheable. Client-side islands fetch a small personalised fragment from a function after load — a "welcome back" line, a currency-converted price — while the page itself stays static. Edge variants serve one of a small number of pre-built pages based on a request attribute, such as /pricing/eu/ versus /pricing/us/ chosen by country, with each variant cached separately. Header injection uses an edge function or HTMLRewriter to modify a cached page in flight — inserting the reader's country into a data attribute — without regenerating it.
On a marketing site that replaced per-request rendering of the pricing page with two edge variants, edge cache hit ratio for that page rose from 0% to 96%, and TTFB p75 fell from 410 ms to 38 ms.
Webhooks and Build Triggers
Not every function faces readers. A second family receives webhooks from other systems and turns them into actions on the static site: a headless CMS publishes an entry and a function triggers a rebuild; a GitHub release is published and a function purges the cached releases widget; a payment provider confirms a purchase and a function adds the buyer to a partner allow-list. These functions are small, but they are the glue that makes a static site feel live.
Three rules keep them reliable. Verify signatures: every serious webhook sender signs its payload with a shared secret (GitHub's X-Hub-Signature-256, Stripe's Stripe-Signature, most CMSs' equivalents); reject anything that does not verify, or a public URL becomes a way for anyone to trigger rebuilds. Respond fast, work later: acknowledge within a second or two and hand the work to a queue or a build hook, because senders retry on timeouts and a slow handler produces duplicate builds. Debounce: a CMS editor saving ten times in a minute should produce one rebuild, not ten — a short delay window in a KV entry or Durable Object collapses bursts. The build side of this is covered in Netlify Build Hooks for Content Updates and Wiring a Headless CMS to a Static Build.
Where Functions Live in Each Stack
Every major static host now accepts functions in the same repository as the site, deployed atomically with it:
| Host | Location in repo | Runtime | Routing |
|---|---|---|---|
| Cloudflare Pages / Workers | functions/ (Pages) or a Worker with static assets | V8 isolate | file path = route |
| Netlify | netlify/functions/, netlify/edge-functions/ | Node or Deno (edge) | /.netlify/functions/* or path config |
| Vercel | api/ | Node or Edge | file path = route |
| AWS | separate Lambda / CloudFront Function | Node | CloudFront behaviour or API Gateway |
Keeping functions in the site's repository means the form handler and the form markup change in the same pull request, deploy together, and roll back together — the same atomic guarantee static files get. The generator does not matter: an Astro, Hugo or Eleventy build output sits next to the functions directory, and the host wires both up.
Testing Functions Locally and in CI
Functions deserve the same test discipline as the rest of the pipeline, and they are easier to test than most server code because each is a single function from request to response. Unit-test the handler by constructing a Request and asserting on the Response; the Workers runtime, Netlify and Vercel all expose standard Fetch API objects, so tests run in plain Node or in vitest with the platform's test environment. Run the full local stack with wrangler pages dev, netlify dev or vercel dev to exercise routing alongside the built site. And add the function routes to the post-deploy smoke tests against every preview: a form endpoint should return 400 on an empty POST and 303 on a valid one, which proves both validation and routing without sending a real message.
Operating Functions Safely
Functions are server code, and bring server concerns:
- Secrets live in the platform's secret store (
wrangler secret put, Netlify and Vercel environment variables), never in the repository or the built output. - Rate limits on public endpoints: a form or proxy without one is an invitation to abuse. Cloudflare's rate-limiting rules or a counter in KV or Durable Objects cover it.
- Input validation for every field, with strict length limits and allow-lists rather than trying to block bad input.
- Observability: log function errors and latency the same way you monitor the rest of the site, as described in Monitoring Static Sites in Production.
- Previews: functions deploy with every preview, so they must not send real emails or write to production data from preview environments. Gate side effects on an environment variable.
- Timeouts to upstreams: set an explicit timeout on every outbound
fetch, and return a graceful fallback, so a slow third party cannot hold a function — and the reader waiting on it — for the platform's maximum duration.
Cost Model
Function pricing is per request and per unit of compute, which makes it cheap for the traffic static sites actually send to functions — a small fraction of page views. On the site measured below, functions received about 1.4% of all requests: form submissions, the releases widget's cached API calls, and partner-page auth checks. At 1.2 million page views a month that was roughly 17,000 function invocations, well inside the free tiers of every platform listed above. The partner section was the exception, because the auth gate runs on every request to every protected page and asset; its 90,000 monthly requests pushed the site onto a paid Workers plan at 5 USD a month, still less than the VPS it replaced.
Two habits keep costs predictable. Route only the paths that need code through functions — on Cloudflare Pages, _routes.json excludes static paths from invoking functions at all; on Netlify and Vercel, keep functions under distinct paths rather than catch-all middleware. And cache function responses wherever the data allows, as in the releases proxy above, so repeated requests are answered from cache rather than from compute.
Measured Impact
A documentation and marketing site moved four features from third-party embeds and a small VPS to edge functions on Cloudflare: the contact form, a newsletter signup, a GitHub releases widget and a partner-only section.
| Measure | Before | After |
|---|---|---|
| Third-party scripts on affected pages | 3 (form embed, newsletter widget, GitHub badge) | 0 |
| JavaScript on the contact page (compressed) | 142 KB | 3 KB |
| Contact page LCP p75 (mobile) | 2.9 s | 1.4 s |
| Spam submissions per week | ~340 | ~2 |
| Servers to patch | 1 VPS | 0 |
| Monthly cost | 24 USD (VPS + form service) | 5 USD (Workers paid plan) |
Common Pitfalls
- Functions on the page-load path. Rendering pages per request discards the CDN cache. Keep pages static; call functions after load or on submit.
- Regional functions for global audiences. A function in one region adds hundreds of milliseconds for distant readers. Prefer edge unless you need regional capabilities.
- Secrets in client bundles. Any key in front-end code is public. Proxy through a function.
- No rate limiting. Public endpoints get abused within days of launch.
- Side effects in previews. Preview deploys run the same functions; stub email and writes outside production.
- Caching personalised responses publicly. Use
Cache-Control: privateor vary the cache key correctly, or one reader's data reaches another. - Catch-all middleware. A function that runs on every path, including images and scripts, multiplies invocations and adds latency to assets that never needed code. Scope routes narrowly.
Key Takeaways
- Functions add the dynamic 5% a static site needs without giving up static pages or CDN caching.
- Edge functions start in milliseconds and respond in tens of milliseconds worldwide; regional functions are for heavier work.
- Forms, API proxies, auth gates and light personalisation cover almost every static-site use case.
- Keep functions off the page-load path; personalise with islands, edge variants or header injection.
- Treat functions as server code: secrets, rate limits, validation, monitoring and safe previews.
FAQ
Does adding a function make a site no longer static?
No. The pages stay pre-built files served from the CDN. A function handles only the specific routes that need to run code, such as a form endpoint or an API proxy, so the rest of the site keeps its static performance and simplicity.
Edge functions or regional serverless functions?
Edge functions run in many locations close to readers with fast cold starts and suit short request handling, redirects, headers and lightweight APIs. Regional functions run in one location with fuller Node.js support and longer limits, and suit heavier work or anything that needs a database in the same region.
What are the most common uses on static sites?
Contact and signup forms, proxying third-party APIs to hide keys, gating content behind authentication, lightweight personalisation such as geolocation, image or OG generation on demand, and webhooks that trigger rebuilds.
How do I keep functions from slowing down pages?
Keep them off the page-load path. Pages should be static and cached; functions should answer separate requests made after load or on form submission. If a function must run on page requests, keep it at the edge and cache its output.
Are functions a security risk on a static site?
They add attack surface, so treat them like any server code. Validate input, rate-limit public endpoints, keep secrets in the platform's secret store, and give each function only the permissions it needs.
Related
- Up: Production-Ready Deployment & CI/CD Workflows — where functions fit in the deploy pipeline.
- Handling Form Submissions on a Static Site — options from hosted services to your own function.
- Adding a Contact Form with Cloudflare Workers — an end-to-end build.
- Netlify Functions vs Cloudflare Workers — choosing a platform.
- Proxying Third-Party APIs from an Edge Function — hiding keys and caching responses.
- Protecting a Static Site Behind Authentication — gating static content.
- Vercel ISR vs Static Generation for SSGs — when regeneration beats functions.
- Netlify Build Hooks for Content Updates — functions that trigger rebuilds.