Guide for writing Netlify Edge Functions. Use when building middleware, geolocation-based logic, request/response manipulation, authentication checks, A/B testing, or any low-latency edge compute. Covers Deno runtime, context.next() middleware pattern, geolocation, and when to choose edge vs serverless.
// netlify/edge-functions/hello.ts
import type { Config, Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => {
return new Response(`Hello from ${context.geo.country?.name ?? "somewhere"}`);
};
export const config: Config = { path: "/hello" };
config export with the route. Alternatively, declare the route in netlify.toml.fetch method. Netlify calls fetch with the same (request, context) arguments and uses its return value the same way; inside fetch, this is the exported object. Use it to share one handler with other platforms that accept Fetchable modules.
export default {
async fetch(request: Request, context: Context) {
return new Response("Hello");
},
};
export const config: Config = { path: "/hello" }; // still a named export
Request and Response are global. Import Context and Config types from @netlify/edge-functions.Netlify.env.get(). process.env works only for Next.js middleware.Avoid these:
config.path or pattern and no [[edge_functions]] entry. It deploys but never runs.deno.json. They are unsupported. Use a separate file with [functions] deno_import_map.netlify.toml. They are invisible to edge functions.Netlify.env.set() or Netlify.env.delete() as persistence. They affect only the current invocation.fetch() to the same site for the current path. Use context.next() instead.path: "/*" runs on every request, including assets. Narrow it, or add excludedPath.cache: "manual" in config AND cache headers on the returned Response. With only one, nothing is cached./* serves /cat.png instead of the file.Response ends the chain. Redirect rules for that path never run.fetch() and URL rewrites start a new request chain. Matching edge functions run again. Use context.next() to reach the static asset or serverless function at the same path.await req.json(), pass new Request(req, { body }) to context.next().foo.ts and foo.js: the TS file is ignored and the JS file deploys.waitUntil work.netlify.app is on the Public Suffix List. Cross-subdomain cookies need a custom domain.| What | Where |
|---|---|
| Functions | <base>/netlify/edge-functions/*.{js,ts,jsx,tsx} |
| Custom dir | [build] edge_functions = "my-dir" (relative to base dir; keep it outside the publish dir) |
| Name in toml | file name without extension (auth.ts → function = "auth") |
| Import map | any JSON file, referenced by [functions] deno_import_map = "./import_map.json" |
| Return | Effect |
|---|---|
Response | sent to client; chain ends |
URL | same-site rewrite, 200, address bar unchanged |
undefined / return; | skip this function; chain continues |
export default async () =>
new Response("Hello, World!", { headers: { "content-type": "text/html" } });
export const config = { path: "/api/hello" };
import type { Context } from "@netlify/edge-functions";
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)); // redirect
// or: return new URL("/subscriber-sale", req.url); // rewrite (same-site only)
}
};
export const config = { path: "/sale" };
Rewrites must target the same site. To reach other sites, use fetch.
import type { Context } from "@netlify/edge-functions";
export default async (request: Request, context: Context) => {
if (new URL(request.url).searchParams.get("method") !== "transform") return;
const response = await context.next();
return new Response((await response.text()).toUpperCase(), response);
};
export const config = { path: "/blog/*" };
Call context.next() only when you need the response body. If you just return undefined, the chain continues on its own.
export default async (req: Request, { next }: Context) => {
const res = await next({ sendConditionalRequest: true });
if (res.status === 304) return res;
return new Response((await res.text()).toUpperCase(), res);
};
By default, next() strips conditional headers, so it always returns a full response.
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) }));
};
import type { Config, Context } from "@netlify/edge-functions";
export default async (req: Request, context: Context) => {
const user = context.user; // undefined: public project, password protection, netlify dev
if (!user) return new Response("Forbidden", { status: 403 });
const allowed = (Netlify.env.get("ADMIN_USER_IDS") ?? "").split(",");
if (!allowed.includes(user.id)) return new Response("Forbidden", { status: 403 });
// user.email may be undefined, so use it for display only
};
export const config: Config = { path: "/admin/*" };
export default async (req: Request, context: Context) => {
context.waitUntil(fetch("https://analytics.example.com/event", { method: "POST" }));
return new Response("ok");
};
waitUntil does not block the response, but it still counts against the 50 ms CPU limit.
.tsx)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 (req: Request, context: Context) => {
const stream = await renderToReadableStream(
<html><body><h1>Hello {context.geo.country?.name}</h1></body></html>
);
return new Response(stream, { headers: { "Content-Type": "text/html" } });
};
export const config: Config = { path: "/hello" };
Context| Property | Notes |
|---|---|
cookies.get(name) / .set(options) / .delete(name | options) | set and delete use CookieStore option format; they act on the outgoing response |
geo | city, country.{code,name}, subdivision.{code,name}, latitude, longitude, timezone, postalCode (format varies) |
ip | client IP |
next(options?) / next(request, options?) | Promise<Response> from the next item in the chain |
params | URLPattern named groups from path (/pets/:name → { name }). For the query string, use request.url |
user | { id, email?, expiresAt } or undefined. It is a Netlify account, not Netlify Identity; for Identity, use getUser() from @netlify/identity |
waitUntil(promise) | runs after the response is sent |
requestId, account.id, server.region | — |
site | { id, name, url } |
deploy | { context, id, published, skewProtectionToken } |
Netlify adds no client-info headers to the request. Read client info from context instead.
Netlify.context gives the same object, but only inside the handler. Outside it, it is null.
Netlify.env: get(name), has(name), toObject(), and invocation-scoped set and delete. To change variables persistently, use the env API.
Web APIs:
console, atob, btoafetch, Request, Response, URL, File, BlobTextEncoder/TextDecoder (and their Stream variants), PerformancerandomUUID, getRandomValues, SubtleCryptosetTimeout, clearTimeout, setIntervalReadableStream, WritableStream, TransformStreamModules:
node: prefix (import { randomBytes } from "node:crypto").config| Key | Value |
|---|---|
path | URLPattern string or array; must start with / |
excludedPath | URLPattern string or array; must start with / |
pattern / excludedPattern | regex alternatives to path / excludedPath |
method | HTTP method string or array |
header | { name: true | false | "regex" }; case-insensitive names; multi-value headers match the comma-joined list |
onError | "fail" (default), "/custom-path", or "bypass" |
cache | "manual" |
export const config: Config = {
path: "/*",
excludedPath: ["/*.css", "/*.js", "/img/*"],
header: { "user-agent": "(iPhone|Android)" },
};
The netlify-agent-category request header maps any user-agent to a client category.
netlify.tomlThe toml form supports function, path, excludedPath, pattern, excludedPattern, [edge_functions.header], and cache. Set onError inline. Use toml when you need explicit ordering of several functions on one path.
[[edge_functions]]
path = "/admin"
function = "auth"
[[edge_functions]]
path = "/admin"
function = "injector"
cache = "manual"
[[edge_functions]]
pattern = "/products/(.*)"
excludedPattern = "/products/things/(.*)"
function = "highlight"
[[edge_functions]]
path = "/*"
excludedPath = "/img/*"
function = "common"
[edge_functions.header]
x-required = true
x-forbidden = false
A request to /admin runs auth, then common, then injector.
Netlify walks the following order twice for the request path. The first pass runs only functions without caching configured. The second pass runs only cached functions.
netlify.toml declarations, top to bottom.If the same function is declared both in toml and inline, the configs are merged and treated as step 4; inline wins for duplicate fields.
After the edge functions, redirect rules run, unless a function already returned a Response. A redirect to a new path repeats the whole process for that path.
An edge function declared on the target of a static rewrite does not run for rewritten requests.
import type { Config } from "@netlify/edge-functions";
export default async () =>
new Response("Hello world", { headers: { "cache-control": "public, s-maxage=3600" } });
export const config: Config = { path: "/hello", cache: "manual" };
Cache-Control, CDN-Cache-Control, Netlify-CDN-Cache-ControlExpires (overridden by max-age/s-maxage)Vary, Netlify-VaryonError)| Value | Effect |
|---|---|
"fail" (default) | chain stops; generic error page is shown |
"/unavailable" | rewrite to that same-site path; edge functions on it are not invoked |
"bypass" | skip the failing function; chain continues |
Fail closed for critical logic: use the default for internal auth, or a custom path for customer-facing auth. Fail open (bypass) for nice-to-haves such as localization.
context, not from env vars.npm install netlify-cli -g, then netlify dev. Visit http://localhost:8888/<path>.
--edge-inspect or --edge-inspect-brk.--geo=mock (San Francisco), or --geo=mock --country=DE.context.user is undefined locally.https://app.netlify.com/projects/{site_name}/edge-functions).
console.log output is tagged with the function name.| Limit | Value |
|---|---|
| Bundle size | 20 MB compressed |
| Memory (per set of deployed functions) | 512 MB |
| CPU time per request | 50 ms (excludes I/O wait) |
| Response header timeout | 40 s |
Docs: API · Declarations · Optional configuration · Limits · Examples
<!-- Gaps: no legacy/deprecated handler form is documented; whether `method` works in netlify.toml is unstated. --> <!-- Undocumented: context.params with regex `pattern` declarations; monthly invocation limits per plan. --> <!-- Undocumented: whether Netlify uses Fetchable module members other than `fetch`. --> <!-- system: agent-context/edge-functions/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->These are org conventions and field-learned guardrails, not docs facts — they are merged into the rendered skill by ctx-gen and are never generated. Extracted from the previous hand-written netlify-edge-functions skill; owned by the skills maintainer.
path narrowly. path: "/*" intercepts every request — including
static assets — adding latency to each one and billing an edge invocation
for it.cache: "manual" in
config — it's both or neither. Setting Cache-Control on the returned
Response has no effect unless the function also opts in.context.user
is the Netlify user who made the request (id, email, expiresAt).
Use it for per-person checks and personalization instead of adding a
second login, and never take identity from client-sent headers or
cookies: Netlify verifies the login before the edge function runs. Every
example must handle context.user being undefined (public projects,
basic password protection, netlify dev), and email can be
undefined too, so key decisions on id. This is a Netlify account,
not a Netlify Identity user.skillbazaar install netlify-edge-functions --agent claudeSign in (free) to install skills with the CLI.
Author
@netlify
on GitHub
Published by