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.

Order of processing for a request on Netlify with edge functions A request arrives at a Netlify edge node. Edge functions whose paths match run first, in declared order. When a function calls context.next, redirect and rewrite rules from _redirects apply, then the static file is served with _headers. The response passes back through each function, which can modify it before it reaches the reader. Where edge functions run in a Netlify request Edge functions matching paths only geo notice, pricing rewrite, banner _redirects redirects and rewrites after context.next() Static file + _headers from the CDN cached as usual Response back through functions modify on the way out headers or HTML transforms
Functions wrap the static response; with excludedPath, assets bypass them entirely.

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).

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.

Sequence of setting and serving a site-wide banner through an edge function An editor sets the banner text in an internal form, which writes it to Netlify Blobs. A reader requests any page. The edge function continues to the static file, reads the banner from the blob store and streams the page with the banner inserted at the top of the body. When the banner is cleared, the function passes pages through unchanged. Turning a site-wide banner on without a rebuild Editor internal form Netlify Blobs banner key Edge function banner.ts Reader any page set banner text GET /guide/install/ read banner (eventual) static page + banner streamed
The banner appears within seconds of being set; no build or deploy is involved.

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:

FunctionPathsAdded TTFB p50 / p95
geo-class/pricing/4 ms / 11 ms (buffers the page)
pricing-ab/pricing/2 ms / 6 ms
banner (empty)all HTML3 ms / 9 ms (blob read)
banner (set)all HTML5 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.

Median and 95th percentile latency added by each edge function Paired bars of added time to first byte. The geolocation class function added 4 milliseconds at the median and 11 at the 95th percentile. The pricing experiment function added 2 and 6. The banner function with no banner set added 3 and 9, and with a banner set 5 and 14. Added time to first byte per function p50 p95 geo-class (buffers page) 4 ms 11 ms pricing-ab (rewrite) 2 ms 6 ms banner (empty) 3 ms 9 ms banner (set) 5 ms 14 ms
Synthetic checks from six regions, 500 requests per function, compared with the same paths without the function.

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

MeasureBefore (client-side logic)After (edge functions)
CLS p75 on pricing page0.09 (notice inserted by JS)0.01
JavaScript for geo and experiment logic14 KB + geolocation API call0
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 with HTMLRewriter for site-wide transforms.
  • Caching personalized responses. Mark per-reader responses private, or use Netlify-Vary deliberately.
  • 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.

Filed underHow-tosNetlify