Netlify Edge Functions for Static Sites
Netlify's static hosting covers most of what a static site needs with configuration alone: _redirects, _headers, deploy previews, forms. Some requirements sit just beyond configuration — a banner that depends on the reader's country, a rewrite based on a cookie, a header value computed per request, an HTML tweak that should apply to every page without rebuilding. Netlify Edge Functions run small TypeScript functions at the edge before the static file is served, with access to the request, the static response, geolocation and cookies.
This guide adds three edge functions to a static marketing site on Netlify — a geolocation-based notice, a cookie-driven rewrite for a pricing experiment, and an HTML transform that injects a build-time banner — scopes them to the paths that need them, keeps static caching intact where possible, and measures the latency they add. It is part of Netlify vs Vercel Deployment Strategies, and the Cloudflare equivalent is Cloudflare Pages Functions Middleware.
Prerequisites
- A static site deployed on Netlify (any generator).
- Netlify CLI 17+ for local development with
netlify dev. - A clear list of what must happen at request time and could not happen at build time.
How Edge Functions Fit In
Edge functions live in netlify/edge-functions/ and declare the paths they run on. For each request, Netlify runs matching edge functions in order; each can return a response directly or call context.next() to continue to the static file (or a later function), then modify what comes back:
// netlify/edge-functions/example.ts
import type { Config, Context } from '@netlify/edge-functions';
export default async (request: Request, context: Context) => {
const response = await context.next();
response.headers.set('X-Edge', 'on');
return response;
};
export const config: Config = { path: '/*', excludedPath: ['/assets/*', '/img/*', '/*.css', '/*.js'] };
excludedPath keeps the function off assets, so images and scripts are served straight from the CDN. Declaring paths in the function file rather than netlify.toml keeps the scope next to the code.
Function 1: A Country-Specific Notice
The site needed to show EU readers a short note about data residency on the pricing page. Rather than detecting location in client JavaScript (which needs a geolocation API call and shifts the layout when the note appears), an edge function adds a class to <html> based on Netlify's geolocation, and CSS shows or hides the note, which is already in the static HTML:
// netlify/edge-functions/geo-class.ts
import type { Config, Context } from '@netlify/edge-functions';
const EU = new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT','LU','MT','NL','PL','PT','RO','SK','SI','ES','SE']);
export default async (_req: Request, context: Context) => {
const res = await context.next();
if (!res.headers.get('content-type')?.includes('text/html')) return res;
const region = EU.has(context.geo?.country?.code ?? '') ? 'eu' : 'other';
const html = (await res.text()).replace('<html ', `<html data-region="${region}" `);
const out = new Response(html, res);
out.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
out.headers.set('Vary', 'Accept-Encoding');
out.headers.set('Netlify-Vary', 'header=x-country');
return out;
};
export const config: Config = { path: '/pricing/' };
.eu-notice { display: none; }
[data-region="eu"] .eu-notice { display: block; }
Because the notice's space is decided before the page reaches the browser, there is no layout shift. The function runs only on /pricing/. Reading the whole body with res.text() is acceptable for one small page; for site-wide transforms, streaming is better (see Function 3).
Function 2: A Cookie-Driven Pricing Experiment
A pricing experiment served one of two static variants, /pricing/ and /pricing/b/, at the same URL. An edge function assigns a variant, stores it in a cookie, and rewrites to the variant's static file:
// netlify/edge-functions/pricing-ab.ts
export default async (request: Request, context: Context) => {
let variant = context.cookies.get('ab_pricing');
if (variant !== 'a' && variant !== 'b') {
variant = Math.random() < 0.5 ? 'a' : 'b';
context.cookies.set({ name: 'ab_pricing', value: variant, path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'Lax', secure: true });
}
if (variant === 'b') return context.rewrite('/pricing/b/');
};
export const config: Config = { path: '/pricing/' };
Returning undefined for variant A continues to the normal static file. context.rewrite serves /pricing/b/ without changing the URL. Netlify's built-in split testing works at the branch level instead and suits whole-site experiments; the edge function suits one page with two static variants, as discussed in Feature Flags on Static Sites.
Function 3: A Site-Wide Banner Without a Rebuild
Occasionally marketing needed a banner across the whole site within minutes — an incident notice, an event announcement — without waiting for a rebuild. An edge function reads the banner text from Netlify Blobs (a small key-value store) and injects it with HTMLRewriter, which streams the page instead of buffering it:
// netlify/edge-functions/banner.ts
import { HTMLRewriter } from 'https://ghuc.cc/worker-tools/html-rewriter/index.ts';
import { getStore } from '@netlify/blobs';
export default async (_req: Request, context: Context) => {
const res = await context.next();
if (!res.headers.get('content-type')?.includes('text/html')) return res;
const banner = await getStore('site').get('banner', { consistency: 'eventual' });
if (!banner) return res;
return new HTMLRewriter()
.on('body', { element: (el) => el.prepend(`<div class="site-banner" role="status">${escapeHtml(banner)}</div>`, { html: true }) })
.transform(res);
};
export const config: Config = { path: '/*', excludedPath: ['/assets/*', '/img/*', '/fonts/*'], cache: 'manual' };
When the blob is empty, the function returns the static response untouched. Editors set or clear the banner through a tiny internal form that writes to the blob store. The banner occupies space at the top of the body, so to avoid layout shift it is rendered before first paint — it is in the HTML — rather than inserted by client script.
Latency and Cost
Edge functions add work to every request they run on, so scope and speed matter. Measured with a synthetic check from six regions, 500 requests each:
| Function | Paths | Added TTFB p50 / p95 |
|---|---|---|
| geo-class | /pricing/ | 4 ms / 11 ms (buffers the page) |
| pricing-ab | /pricing/ | 2 ms / 6 ms |
| banner (empty) | all HTML | 3 ms / 9 ms (blob read) |
| banner (set) | all HTML | 5 ms / 14 ms |
Without excludedPath, the banner function also ran for every asset — 23 requests per page view instead of one — which added up to a noticeable share of the plan's edge function invocations. Scoped, the site used about 2.6 million invocations a month, within its plan.
Operating Edge Functions
Edge functions are code in the request path, so they need the same care as any production code. Each function has a test that runs with netlify dev in CI and asserts its behaviour on a representative request — the header table from Testing Redirects and Headers Before Deploy gained rows for the region attribute, the variant cookie and the banner. Errors inside a function fail the request by default, so every function wraps its logic and falls back to context.next() on exceptions; a broken banner function should serve pages without a banner, not a 500. Netlify's function logs are reviewed after each deploy that touches netlify/edge-functions/, and the error tracker receives exceptions tagged with the function name. Finally, each function has an owner and a reason recorded in a comment at the top of the file, which made it easy to delete the pricing experiment when it ended rather than leaving it running indefinitely.
Measured Impact
| Measure | Before (client-side logic) | After (edge functions) |
|---|---|---|
| CLS p75 on pricing page | 0.09 (notice inserted by JS) | 0.01 |
| JavaScript for geo and experiment logic | 14 KB + geolocation API call | 0 |
| Time to publish a site-wide banner | ~4 min (rebuild) | ~10 s |
| Edge function invocations per month | — | 2.6 million (scoped) |
| TTFB p75 added on affected pages | — | 5 ms |
Pitfalls & Rollback
- Running on assets. Use
excludedPath; otherwise every image and script invokes the function. - Buffering large pages.
res.text()holds the whole page in memory; stream withHTMLRewriterfor site-wide transforms. - Caching personalized responses. Mark per-reader responses private, or use
Netlify-Varydeliberately. - Uncaught exceptions. Fall back to the static response rather than failing the request.
- Rollback: delete the function file or narrow its path, and redeploy; static files are served as before.
Conclusion
Netlify Edge Functions let a static site do a few things at request time that configuration cannot: decide a class from the reader's country, choose a static variant from a cookie, or inject a banner without a rebuild. Keep each function scoped to the paths that need it, leave static caching intact where possible, stream rather than buffer, and treat the functions as production code. On this site they removed client-side logic that caused layout shift and made urgent banners a ten-second change.
FAQ
What are Netlify Edge Functions?
Functions written in TypeScript or JavaScript that run on Deno at Netlify's edge locations before a request reaches the static file or a serverless function. They can rewrite, redirect, add headers, transform responses or respond directly, with low latency close to the reader.
How are Edge Functions different from Netlify Functions?
Netlify Functions are serverless functions running in a single region on AWS Lambda, suited to heavier work such as form processing or API calls with longer execution. Edge Functions run in many locations with tighter limits on execution time and memory, suited to fast request and response manipulation.
Do Edge Functions stop static pages from being cached?
Not necessarily. An edge function that only adds a header or rewrites can pass the static response through with its caching intact. Functions that personalize the response per request should mark it private, and Netlify can cache edge function responses when configured with cache set to manual.
How do I limit which paths run an edge function?
Declare paths in the function's config export or in netlify.toml, and use excludedPath for assets. Keeping edge functions off asset paths avoids adding latency and invocations to every image and script request.
Related
- Parent: Netlify vs Vercel Deployment Strategies — choosing and configuring a host.
- Netlify Redirects and Rewrites for Static Sites — what configuration handles without code.
- Netlify Functions vs Cloudflare Workers — serverless options compared.
- Rate Limiting Edge Functions — protecting edge endpoints from abuse.
- Vercel Build Output API for Custom SSGs — the Vercel way to add edge logic.