How to run code at the edge with Netlify Edge Functions
Edge Functions run on Deno at the network edge for auth checks, geo redirects, and A/B tests. The handler shape, routing, and why an unrouted function silently never runs.
Key takeaways
Put a file in netlify/edge-functions/, export a default handler taking a Request and a Context, and export a config with a narrowly-scoped path. It runs on Deno at the network edge, close to your visitor. Return a Response to answer directly, a URL to rewrite same-site, or nothing to let the request continue.
- A function without a route silently never runs. No config export and no
netlify.tomlentry means it deploys clean, with no error and no warning, and never executes. - Scope
pathnarrowly.path: "/*"intercepts every request including static assets, adding latency and billing an invocation for each. - Check your framework adapter first. Next.js, Astro, Remix, SvelteKit, and Nuxt generate edge middleware. Duplicating it causes conflicts.
- Caching needs both parts. Cache headers do nothing without
cache: "manual", andcache: "manual"without headers caches nothing. - CPU limit is 50 ms per request. This is compute time, not time spent waiting on resources.
- Env vars in
netlify.tomlare not available here. Scope must include Functions.
Close to the visitor, before the origin
Some work is worth doing before a request reaches your application at all. Checking whether someone’s logged in. Sending a visitor in Paris to the French page. Splitting traffic for an experiment. Rewriting a URL. Doing any of that at your origin means a round trip you didn’t need.
Edge Functions run that code at the network edge on a Deno runtime. The programming model is small: a request comes in, you look at it, and you either answer it, redirect it, rewrite it, or wave it through. What makes them worth understanding carefully is that several of the failure modes are silent, which is unusual and worth knowing up front.
When to use edge, and when to use serverless
Edge suits low-latency request and response manipulation: auth checks and redirects, geolocation logic, A/B personalization, content localization, header and body transforms, SSR at the edge.
Serverless suits everything shaped differently: long-running work up to 15 minutes, heavy Node dependencies, database-heavy operations, background and scheduled tasks, or memory above 512 MB. See How to add a backend API endpoint with Netlify Functions.
Before you write one, check whether your framework’s adapter already generates it. Next.js Middleware, Astro middleware, and the SvelteKit, Remix, and Nuxt equivalents all compile to edge functions already. Hand-writing one that duplicates adapter middleware causes conflicts. Look at How to deploy Next.js, Astro, Nuxt, or SvelteKit to Netlify first.
A few places Edge Functions don’t reach at all: Split Testing disables them entirely, custom headers (including basic auth headers) don’t apply to them, prerendering doesn’t apply to paths they serve, and they’re not part of Netlify’s HIPAA-compliant offering.
Worked example
import type { Config, Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => { // return Response | URL (rewrite) | undefined (continue chain)};
export const config: Config = { path: "/products/*" };Files go in YOUR_BASE_DIRECTORY/netlify/edge-functions, or a custom directory set with the edge_functions key under [build] in netlify.toml. Keep it outside your publish directory so source files aren’t deployed. .js, .ts, .jsx, and .tsx all work, though if a .ts and .js share a name, the .ts is ignored and the .js deploys.
Redirect on geo and cookie
export default async (req: Request, { cookies, geo }: Context) => { if (geo.city === "Paris" && cookies.get("promo-code") === "15-for-followers") { return Response.redirect(new URL("/subscriber-sale", req.url)); }};Returning nothing lets the request continue, which is what happens for everyone who isn’t in Paris with that cookie.
Rewrite
export default async (request: Request, { geo }: Context) => { if (geo.city === "Paris") return new URL("/subscriber-sale", request.url);};Returning a URL rewrites to a same-site URL with a 200 status and leaves the address bar unchanged. To reach another site or external content, use fetch() instead.
Transform the response
import type { Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => { const response = await context.next(); const text = await response.text(); return new Response(text.toUpperCase(), response);};context.next() runs the rest of the chain and hands you the origin Response. Only call it when you actually need the body, because it costs latency otherwise.
To transform a different path, use fetch(). Be aware that starts a new request chain and re-runs any edge functions matching that path. context.next() reaches a static asset or serverless function at the same internal path without re-running edge functions.
Reading the request body
A body can only be read once, so pass a fresh request to next():
export default async (req: Request, context: Context) => { const body = await req.json(); if (!isValid(body.access_token)) return new Response("forbidden", { status: 403 }); return context.next(new Request(req, { body: JSON.stringify(body) }));};Response caching
Both halves are required. Headers without cache: "manual" do nothing, and cache: "manual" without headers caches nothing.
import type { Config, Context } from "@netlify/edge-functions";
export default async (req: Request, context: Context) => { return new Response("Hello world", { headers: { "cache-control": "public, s-maxage=3600" }, });};
export const config: Config = { cache: "manual", path: "/hello" };Use caching only for endpoint-style responses that are reusable across clients, like shared SSR HTML. Never for middleware, routing, or per-client personalization. Cached responses don’t count toward invocations, which is a nice side effect.
SSR at the edge
import React from "https://esm.sh/react";import { renderToReadableStream } from "https://esm.sh/react-dom/server";import type { Config, Context } from "@netlify/edge-functions";
export default async function handler(req: Request, context: Context) { const stream = await renderToReadableStream( <html><body><h1>Hello {context.geo.country?.name}</h1></body></html> ); return new Response(stream, { status: 200, headers: { "Content-Type": "text/html" } });}
export const config: Config = { path: "/hello" };Ordering multiple functions on a path
[[edge_functions]] path = "/admin" function = "auth"
[[edge_functions]] path = "/admin" function = "injector" cache = "manual"Header matching uses an [edge_functions.header] sub-table.
Execution order: config-file declarations run before inline ones, framework-generated before user-written, and non-cached before cached. Within netlify.toml it’s top to bottom. Within inline config it’s alphabetical by file name, which is rarely what you want, so use netlify.toml when order matters. If the same function is declared both inline and in toml, the two merge and inline fields win.
Common failure modes
Your edge function does nothing and there’s no error. Check the route first. Edge functions are not auto-assigned a URL. With no config export and no netlify.toml declaration, it deploys cleanly with no build error and no warning, and never executes.
Everything got slower and your invocation count jumped. path: "/*". It intercepts every request including static assets, adding latency to each and billing an invocation for each. Match only what you need.
Your cached function is serving instead of a real file. A cached function shadows static files. cache: "manual" on /* makes /cat.png serve the function rather than the image.
Your cache headers do nothing. Missing cache: "manual". Or you set them somewhere other than inline in code, which is required.
Caching seems broken locally. There’s no local caching. Cache headers are ignored under netlify dev.
Your cached response disappeared after a deploy. A new deploy in the same context voids s-maxage, max-age, and Expires, because deploys are atomic.
Your env var is undefined. Variables in netlify.toml are not available to edge functions. The scope has to include Functions; Build-scoped variables are build-only. And values are frozen at deploy time, so changing one needs a new deploy.
You set an env var at runtime and it didn’t stick. Netlify.env.set and delete are invocation-scoped only. Use the Netlify env API to actually update a value.
Your geo logic looks broken locally. Mock it: --geo=mock gives San Francisco, or --geo=mock --country=XX for a specific country.
Your function doesn’t run on a rewritten request. A function on the target of a static rewrite does not run for rewritten requests.
Your redirects stopped firing. If a function returns a Response, declared redirects for that path are skipped.
Your cross-subdomain cookie won’t set. netlify.app is on the Public Suffix List. You need a custom domain.
Your npm package fails at the edge. npm support is beta. Packages needing native binaries (Prisma) or runtime dynamic imports (cowsay) may fail. Prefer node: built-ins or Deno URL imports.
Your import map is ignored. Import maps in deno.json are unsupported. Use a separate file declared via deno_import_map in [functions].
You hit the CPU limit on something that mostly waits. The 50 ms limit is CPU time and excludes waiting on resources, but waitUntil work still counts against it.
Your manual deploy errors out. Manual deploys need Netlify CLI 12.2.8 or newer.
Reference
Return values
| Return | Effect |
|---|---|
Response | Respond directly. Ends the chain; declared redirects for the path don’t run. |
URL | Rewrite to a same-site URL with 200 status. Address bar unchanged. |
undefined / bare return; | Bypass this function, continue the chain |
config properties
| Property | Notes |
|---|---|
path | URLPattern string or array. Must start with /. |
excludedPath | Exclude routes from path. Must start with /. |
pattern / excludedPattern | Regex alternatives to path / excludedPath |
method | String or array of HTTP methods. Inline only. |
header | Header conditions: true (present), false (absent), or a regex on the value. Names case-insensitive. |
cache | "manual" to opt into caching |
onError | "fail" (default), a same-site "/path", or "bypass". Inline only. |
Context object
| Property | What you get |
|---|---|
geo | city, country {code,name}, subdivision {code,name}, latitude, longitude, timezone, postalCode |
cookies | get(name), set(options), delete(name|options) (CookieStore standard) |
next(options?) / next(request, options?) | Continue the chain. options.sendConditionalRequest. |
params | Path params. /pets/:name gives { name: "winter" }. Query string via request.url. |
ip, requestId, server.region | Request metadata |
site | id, name, url |
account.id | Account identifier |
deploy | context, id, published, skewProtectionToken |
waitUntil(promise) | Work after the response is sent. Still counts toward the CPU limit. |
Netlify.context gives the same object inside the handler, and null outside it.
Netlify adds no headers to edge requests, so use context for client information.
Error handling
onError value | Behavior |
|---|---|
"fail" (default) | Generic error page, stops the chain |
"/custom-path" | Rewrite to a same-site path, served without invoking that path’s edge functions |
"bypass" | Skip the erroring function, continue the chain |
Fail closed for critical logic like auth. Fail open for progressive enhancement like localization, where bypass is right.
Limits
| Limit | Value |
|---|---|
| Code size | 20 MB compressed (bundle) |
| Memory | 512 MB per deployed set |
| CPU execution | 50 ms per request (excludes resource waiting) |
| Response header timeout | 40 s |
| Invocations per month | Varies by plan. Cached responses don’t count. |
Runtime and modules
Deno, with the standard Web APIs: fetch, Request, Response, URL, console, atob / btoa, TextEncoder and TextDecoder (plus stream variants), Web Crypto (crypto.randomUUID, getRandomValues, subtle), WebSocket, timers, the Streams API, URLPattern, and Performance.
| Import style | Example |
|---|---|
| Node built-ins | import { randomBytes } from "node:crypto" |
| Deno URL modules | import React from "https://esm.sh/react" |
| npm packages (beta) | npm install, then import by name |
Caching headers supported
Cache-Control, CDN-Cache-Control, Netlify-CDN-Cache-Control, Expires, Vary, Netlify-Vary. All must be set inline in code.
Local dev
npm install netlify-cli -gnetlify dev # runs edge functions on local requests at :8888Geo mocking with --geo=mock or --geo=mock --country=XX. Debug with --edge-inspect or --edge-inspect-brk.
Logs live under Logs & Metrics > Edge Functions, where each console log names the emitting function. Filter by name or path glob and time. Retention is at least 24 hours, 7 days on some plans.
FAQs
How do I run middleware at the edge on Netlify?
Create a file in netlify/edge-functions/, export a default handler taking (request, context), and export a config with a path. Return a Response, a URL to rewrite, or nothing to continue the chain.
Why isn’t my edge function running?
Almost certainly it has no route. Edge functions aren’t auto-assigned a URL, and one without a config export or a netlify.toml declaration deploys with no error and never executes.
What’s the difference between Edge Functions and Netlify Functions? Edge Functions run on Deno at the network edge with a 50 ms CPU limit, for low-latency request manipulation. Netlify Functions run on Node with up to 60 seconds (15 minutes in background mode) for heavier work, databases, and scheduled tasks.
How do I do a geolocation redirect?
Read context.geo in an edge function and return Response.redirect(...) when it matches. Test locally with netlify dev --geo=mock --country=DE.
Why are my cache headers being ignored on an edge function?
You need cache: "manual" in the config as well. Headers alone do nothing, and cache: "manual" alone caches nothing.
Can an edge function rewrite to an external URL?
No. Returning a URL rewrites same-site only. Use fetch() to reach external content.
Can I use npm packages in an edge function?
It’s in beta. Install and import by name, but packages requiring native binaries or runtime dynamic imports may fail. Prefer node: built-ins or Deno URL imports.
Why is my env var undefined at the edge?
Variables declared in netlify.toml aren’t available to edge functions. Set it in the UI or CLI with a scope including Functions, then redeploy, since values are frozen at deploy time.
Do edge functions work with Split Testing? No. With Split Testing enabled, edge functions do not run.
Related
This article is generated from Netlify’s open-source agent guidance at netlify/context-and-tools, the same reference our AI coding agents use.
Got something to run at the edge? Start at netlify.new.