Guide for writing Netlify serverless functions. Use when creating API endpoints, background processing, scheduled tasks, or any server-side logic using Netlify Functions. Covers modern syntax (default export + Config), TypeScript, path routing, background functions, scheduled functions, streaming, and method routing.
Reach for the modern default-handler API (.mts TypeScript). Export a default async handler taking a web Request and a Netlify Context, returning a web Response. Avoid the legacy AWS Lambda handler shape unless writing Go or migrating old code (see Legacy at the end).
netlify/functions/ (relative to base directory). Keep it outside your publish directory or source files ship as static assets.index or matches the subdirectory name. All of these create a function hello:
netlify/functions/hello.mtsnetlify/functions/hello/hello.mtsnetlify/functions/hello/index.mts.mts (TS) / .mjs (JS) for ES modules. .cts/.cjs force CommonJS; .ts/.js follow the nearest package.json "type".⚠️ Same-name JS and TS files: the JS one wins. If the functions directory has e.g. items.js and items.ts, the JavaScript function is deployed and the TypeScript file is silently ignored — edits to the .ts have no effect. Keep one file per function name; delete or rename the stale .js when moving a function to TypeScript.
No config export. Serves at /.netlify/functions/hello.
import type { Context } from "@netlify/functions"
export default async (req: Request, context: Context) => {
return new Response("Hello, world!")
}
Install types: npm install @netlify/functions (required for TS types; optional for JS).
Read env vars and secrets with Netlify.env.get():
const apiKey = Netlify.env.get("STRIPE_SECRET_KEY")
Never hardcode secrets. For the variable to exist at runtime its scope must include Functions. Variables set in netlify.toml are NOT available to functions. Values are frozen per deploy — change them and redeploy to apply.
Response headers are set in code on the returned Response. [[headers]] in netlify.toml, _headers, and redirect header rules apply ONLY to static CDN responses, not function responses. Do not add CORS headers unless explicitly requested.
Set config.path to route to custom URLs. When set, the function serves ONLY at that path — not at /.netlify/functions/<name>.
import type { Config, Context } from "@netlify/functions"
export default async (req: Request, context: Context) => {
const { city, country } = context.params
return new Response(`You're visiting ${city} in ${country}!`)
}
export const config: Config = {
path: "/travel-guide/:city/:country",
}
path: ["/cats", "/dogs"].path supports URLPattern syntax — path: ["/sale/*", "/item/:sku"]. Named groups land on context.params. For the query string use req.url.excludedPath: carve exceptions, e.g. excludedPath: ["/product/*.css"] with path: "/product/*".preferStatic: true: let a real static file at the URL win.method: restrict methods, e.g. method: ["GET", "POST"].Equivalent to the default function; carries config inline and lets you add event handlers.
import type { NetlifyFunction } from "@netlify/functions"
export default {
fetch: (req, context) => new Response("Hello, world!"),
config: { path: "/hello" },
} satisfies NetlifyFunction
Second handler argument (or getContext() from @netlify/functions when out of handler scope — throws outside a request; wrap in try/catch).
context.params — named path params.context.geo — city, country.code/name, latitude, longitude, subdivision, timezone, postalCode.context.ip — client IP string.context.cookies — get(name) / set(options) / delete(name|options). Cross-subdomain cookies need a custom domain (netlify.app is on the Public Suffix List).context.site — id, name, url. context.deploy — context, id, published, skewProtectionToken. context.account.id. context.server.region. context.requestId.context.user — the Netlify user who logged in to a private or team-login-protected project: id, email (may be undefined), expiresAt (Date). See below.context.waitUntil(promise) — run work after the response is sent (analytics, logs) without blocking. Billing/log duration counts until the promise settles. Available for functions deployed on/after 2025-03-20.⚠️ context.geo/context.ip are mocked under netlify dev — test with netlify dev --geo=mock --country=DE.
context.user⚠️ context.user is often undefined: public projects, basic password protection, local netlify dev, and functions deployed before October 5, 2026 (redeploy to enable). Always handle it; key records on id (email may be undefined).
import type { Config, Context } from "@netlify/functions"
export default async (req: Request, context: Context) => {
if (!context.user) {
// Public project, basic password protection, local CLI, or pre-2026-10-05 deploy.
return Response.json({ user: null }, { status: 401 })
}
return Response.json({
id: context.user.id,
email: context.user.email ?? null,
expiresAt: context.user.expiresAt.toISOString(),
})
}
export const config: Config = { path: "/whoami" }
Export const config (or the config property of a Fetchable module):
path / excludedPath — string | string[], must start with /.method — one method or array.preferStatic — boolean.background — boolean (see Background).schedule — cron string (see Scheduled). Mutually exclusive with path/excludedPath.rateLimit — { action: 'rate_limit'|'rewrite', aggregateBy: 'domain'|'ip'|[...], to?, windowSize, windowLimit }.memory / vcpu — see below; mutually exclusive.region — airport code; see below.import type { Config } from "@netlify/functions"
import { getDatabase } from "@netlify/database"
const db = getDatabase()
export default async (req: Request) => {
const users = await db.sql`SELECT id, email FROM users LIMIT 10`
return Response.json({ users })
}
export const config: Config = { path: "/users" }
Blobs: import { getStore } from "@netlify/blobs"; getStore("uploads").set(key, await req.blob()).
AI Gateway: construct new OpenAI() with no API key — on credit-based plans AI Gateway sets the provider env vars automatically; then await client.responses.create({ model: "gpt-6-luna", input: [...] }).
Cache: await purgeCache({ tags: ["products"] }) from @netlify/functions invalidates the edge cache (omit tags to purge all).
Return a ReadableStream as the Response body. Limits: 60s execution, 20 MB response.
export default async (req: Request) => {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Netlify.env.get("OPENAI_API_KEY")}`,
},
body: JSON.stringify({ model: "gpt-4o-mini", stream: true, messages: [/* ... */] }),
})
return new Response(res.body, { headers: { "content-type": "text/event-stream" } })
}
config.background: true. Client gets an immediate 202; the return value is discarded; runs up to 15 minutes. No streaming. Retries: on invocation error, retry after 1 min, then again 2 min later. Send results somewhere other than the client.
import type { Config } from "@netlify/functions"
export default async (req: Request) => {
// Long-running work. Client already has its 202.
}
export const config: Config = { background: true, path: "/process" }
Limits: background payload 256 KB. Legacy -background filename suffix still works but prefer config.background.
config.schedule with a cron expression, executed in UTC. The request body is JSON with next_run (ISO-8601). Inline config is TS/JS only — Go must use netlify.toml.
Always compute the UTC time for the target local hour. E.g. 9 AM ET → "0 13 * * *" UTC (note this shifts by an hour across DST; pick the UTC offset you need). Prefer explicit cron over @daily/@hourly shortcuts, which can't target a specific local hour.
import type { Config } from "@netlify/functions"
export default async (req: Request) => {
const { next_run } = await req.json()
console.log("Next invocation at:", next_run)
}
export const config: Config = {
schedule: "0 13 * * *", // 9 AM ET (EST); UTC
}
Via netlify.toml (all languages):
[functions."daily-digest"]
schedule = "0 13 * * *"
Constraints: 30s limit (use background for longer); only fire on published deploys (not Deploy Previews/branch deploys — invoke manually with Run now); no URL invocation; no streaming; no request payloads/POST data; incompatible with Split Testing. All extensions supported except @reboot and @annually.
Export a default object with handlers named after events. They always run in the background — no response to a client. Combine with fetch in the same function. Every handler is fully typed; import event types from @netlify/functions.
import type { DeploySucceededEvent, DeployFailedEvent } from "@netlify/functions"
export default {
deploySucceeded(event: DeploySucceededEvent) {
console.log(`Deploy ${event.deploy.id} succeeded for ${event.site.name}`)
},
deployFailed(event: DeployFailedEvent) {
console.log(`Deploy ${event.deploy.id} failed: ${event.deploy.errorMessage}`)
},
}
Deploy events (event.deploy, event.site; return void): deployBuilding, deploySucceeded, deployFailed, deployDeleted, deployLocked, deployUnlocked.
Identity events (event.user, only id guaranteed):
| Handler | Can deny? | Can mutate? |
|---|---|---|
userValidate | Yes | Yes |
userSignup | Yes | Yes |
userLogin | Yes | Yes |
userModified | Yes | Yes |
userDeleted | No | No |
event.deny() inside the handler → end user gets 401. First function to deny aborts the chain.{ user: {...} } to persist changes; return undefined to pass through.Form events: formSubmitted → event.data (object keyed by field name). Return void.
Multiple functions can handle the same event (all run). Netlify signs each event (JWS) and verifies before invoking, blocking external requests. Legacy filename convention (file named after the event, payload via await req.json() → payload) still works but prefer typed handlers.
⚠️ Do NOT override config.region without a stated reason; the cmh (US East, Ohio) default is deliberate.
When justified — e.g. an EU-resident database:
export const config: Config = { path: "/eu-data", region: "dub" }
Airport codes (self-serve): cmh, dub, fra, gru, iad, lhr, nrt, pdx, sfo, sin, syd, yul. Support-assisted: cdg, mxp. Each function runs in exactly one region (no multi-region geo-routing). Region selection needs Pro/Enterprise. Framework-adapter-generated functions can't take export const config — set region at project level in the UI under Cloud compute > Functions > Region. After changing region, redeploy. Function-level region beats the site-level UI setting.
⚠️ Do NOT set config.memory or config.vcpu speculatively — billing scales linearly with size.
When justified (e.g. observed OOM processing large PDFs):
export const config: Config = { path: "/heavy", memory: "2gb" } // or memory: 2048
memory: 1024–4096 MB. vcpu: 0.5–2.0 (0.5 → 1024 MB, 2.0 → 4096 MB). Mutually exclusive; Netlify sizes the other automatically. Needs Credit-based Pro/Enterprise. Via netlify.toml: [functions.heavy]\n memory = "2gb".⚠️ Files read via fs.readFile at runtime are not bundled (ENOENT in production). Import static data as a module, or declare it:
[functions]
included_files = ["files/*.md"]
external_node_modules = ["package-1"]
⚠️ Combined env vars are capped at ~4 KB for ALL functions — keep PEM keys/service-account JSON out of env vars.
JS-only esbuild: [functions]\n node_bundler = "esbuild".
@netlify/vite-plugin and run the dev server. Next.js and anything else: use the Netlify CLI (netlify dev).netlify functions:invoke <name>.Runtime follows the build's Node.js version (fallback: Node.js 24). Override by setting env var AWS_LAMBDA_JS_RUNTIME (e.g. nodejs24.x) via UI/CLI/API — not netlify.toml — then redeploy. ES modules: __dirname/__filename unavailable, use import.meta.url; named imports of CommonJS packages fail, use a default import.
Go must use the Lambda-compatible API; Go routing/region/memory are set in netlify.toml. For migrating Lambda-style JS/TS, @netlify/aws-lambda-compat wraps an AWS handler:
import { withLambda } from "@netlify/aws-lambda-compat"
import type { HandlerContext, HandlerEvent, HandlerResponse } from "@netlify/aws-lambda-compat"
export default withLambda(async (event: HandlerEvent, context: HandlerContext): Promise<HandlerResponse> => {
const name = event.queryStringParameters?.name ?? "World"
return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ name }) }
})
<!-- Gap: docs only show the .js/.ts same-name precedence; unstated for .mjs/.mts, .cjs/.cts, or subdirectory functions. -->
<!-- system: agent-context/functions/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
These are org conventions, not docs facts — they are merged into the rendered skill by ctx-gen and are never generated. Extracted from the previous hand-written netlify-functions skill; owned by the skills maintainer.
.mts) when possible.Netlify.env.get() (prefer it over
process.env for consistency).context.geo and context.ip are mocked under netlify dev — placeholder
values, not the real location or client IP. Don't conclude geo code is
broken because local values never change; exercise branches with
netlify dev --geo=mock --country=DE and verify on a deploy.config.memory or config.vcpu speculatively. Raise them only
for known memory/compute-intensive work or observed OOM/timeouts caused by
the function's own work — billing scales linearly with size.config.region unless the user has stated a specific
reason (co-located database/backend, data residency, regional audience).
The cmh default is a deliberate choice.fs.readFile on templates, JSON, WASM)
are not bundled: works under netlify dev, ENOENT in production. Prefer
importing static data as a module; otherwise declare the file with a
scoped included_files entry in netlify.toml.config export at all, stating the function serves at
/.netlify/functions/<name>. Custom path routing appears only in a
later example — agents imitate the first example they see.memory, vcpu, or region in a generic example —
show them only attached to an explicit stated reason (observed OOM,
co-located backend, data residency)."0 13 * * *" UTC, noting DST) —
never only @hourly/@daily shortcuts, which can't target a specific
local hour.[[headers]] in netlify.toml, _headers, and
redirect header rules apply ONLY to static CDN responses — response
headers for a function are set in code on the returned Response.not implemented stub behind finished
plumbing: a function whose central branch throws is not a working
answer, however complete its config and routing.config.background: true is the documented, currently-supported way to
make a function background (the -background filename suffix also still
works). If a locally installed bundler doesn't recognize the flag,
suspect version skew first: check and upgrade the local tooling, and
keep local-compatibility findings separate from claims about platform
support — never remove the docs-recommended flag from an answer based
solely on an older installed schema.context.user
is the Netlify user who made the request (id, email, expiresAt).
When an app behind that login needs to know who is calling (per-person
data, audit logs, approvals, per-person settings), read context.user
instead of adding a second login. Netlify verifies the login before the
function runs, so never take identity from client-sent headers or
cookies. Every example must handle context.user being undefined:
public projects, basic password protection, netlify dev, and
functions deployed before October 5, 2026 until the site is redeployed.
email can also be undefined, so key stored records on id. This is
a Netlify account, not a Netlify Identity user; for Identity, use
getUser() from @netlify/identity.skillbazaar install netlify-functions --agent claudeSign in (free) to install skills with the CLI.
Author
@netlify
on GitHub
Published by