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.

Static pages and functions side by side A reader's browser requests pages, which are served from the CDN cache as static files. Separate requests to /api/ routes go to functions: a form handler that writes to storage and sends email, an API proxy that adds a secret key and caches responses, and an auth gate that checks a session before serving protected static files. Pages never wait on functions to render. Pages stay static; code runs only on the routes that need it browser reader static pages CDN cache, ~98% hits POST /api/contact GET /api/releases /partners/* (gated) validate → store → email add secret key → cache 5 min check session → serve static file
The page request never touches a function; functions answer separate, specific requests.

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 endpointCloudflare WorkerNetlify Edge FunctionNetlify Function (Node)Vercel Function (Node)
Cold start (p50)4 ms11 ms290 ms240 ms
Warm response, reader in Frankfurt (p50)22 ms31 ms58 ms (region eu-central)64 ms (fra1)
Warm response, reader in Sydney (p50)26 ms38 ms312 ms330 ms
CPU limit per request (default)10 ms free / 30 s paid50 ms10 s10–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.

Warm response time by reader location Paired bars for a reader in Frankfurt and one in Sydney. The Cloudflare Worker answers in 22 and 26 milliseconds. The Netlify Edge Function in 31 and 38. The Netlify Node function in eu-central answers in 58 from Frankfurt but 312 from Sydney. The Vercel Node function in fra1 answers in 64 and 330. Edge stays flat across the world; regional does not Cloudflare Worker 22 / 26 ms Netlify Edge 31 / 38 ms Netlify Function 58 / 312 Vercel Function 64 / 330 ms Frankfurt Sydney
For a global audience, where the function runs matters more than how fast the code is.

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.

Authentication gate in front of static files A request for a protected page hits an edge function. If the session token is valid, the function fetches the static file and returns it with private cache headers. If not, it redirects to the identity provider's login page. After login, the provider redirects back with a token and the function sets a session cookie. The pages are still static; only access is checked per request GET /partners/… with cookie? edge function verify JWT (~1 ms) valid → serve static file Cache-Control: private missing / invalid → 302 to IdP return with token, set cookie Public pages on the same site bypass the function entirely and stay in the shared cache
Private cache headers on protected responses are the detail that keeps one reader's page out of another's CDN cache hit.

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:

HostLocation in repoRuntimeRouting
Cloudflare Pages / Workersfunctions/ (Pages) or a Worker with static assetsV8 isolatefile path = route
Netlifynetlify/functions/, netlify/edge-functions/Node or Deno (edge)/.netlify/functions/* or path config
Vercelapi/Node or Edgefile path = route
AWSseparate Lambda / CloudFront FunctionNodeCloudFront 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.

MeasureBeforeAfter
Third-party scripts on affected pages3 (form embed, newsletter widget, GitHub badge)0
JavaScript on the contact page (compressed)142 KB3 KB
Contact page LCP p75 (mobile)2.9 s1.4 s
Spam submissions per week~340~2
Servers to patch1 VPS0
Monthly cost24 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: private or 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.