How to add a backend API endpoint with Netlify Functions

Write a Netlify Function in one file, route it where you want, and pick the right execution mode. Includes the limits and the bundling trap that only shows up in production.

Tutorial

Key takeaways

Put a file at netlify/functions/hello.mts that exports a default async handler taking a web Request and returning a web Response. It serves at /.netlify/functions/hello with no configuration at all. To give it a real URL, export a config object with a path. Read secrets with Netlify.env.get(), and set response headers in code on the Response you return.

  • One file is a working endpoint. No config export needed to start.
  • config.path replaces the default URL. Once you set it, the function serves only at that path.
  • Four execution modes: synchronous (60s), scheduled cron (30s), background (15 min), and platform-event handlers. Pick by shape of work, not by preference.
  • netlify.toml headers don’t apply to functions. [[headers]], _headers, and redirect header rules only touch static CDN responses.
  • Files read from disk at runtime aren’t bundled. This works locally and throws ENOENT in production. It’s the single most common surprise.
  • Don’t tune region, memory, or vcpu speculatively. The defaults are deliberate and billing scales with size.

The endpoint you need is smaller than you think

Most of the time you want a backend, you don’t want a backend. You want one URL that does one thing: take a form submission, call an API you’d rather not expose a key for, return some JSON your front end needs. Standing up a server for that is out of proportion to the job.

A Netlify Function is a file. You write a handler, push it, and it’s a live endpoint on your site’s domain with no separate service to deploy or keep running. What follows is the shape of that file, then the parts worth knowing before you’re debugging at 6pm: how routing works, which execution mode fits which kind of work, and the handful of limits that are fixed.

When to use a function, and when not to

Reach for a Function when you need server-side work tied to your site: an API route, a form handler, a proxy that keeps an API key off the client, a webhook receiver, a scheduled job, a long-running batch task.

Reach for something else when:

  • You need to run at the network edge, close to the user, for redirects, geolocation routing, A/B tests, or middleware. That’s Edge Functions, a Deno runtime with different tradeoffs.
  • You’re writing Go. Go functions must use the Lambda-compatible API, and routing, region, and memory get set in netlify.toml rather than in code.
  • You’re storing data. Functions are compute. Objects and file uploads go to Netlify Blobs; relational and transactional data goes to Netlify DB.
  • You just need response headers on static files. That’s _headers or netlify.toml, and notably it won’t work on function responses.

Prefer the modern default-handler API in TypeScript (.mts). The legacy AWS Lambda handler shape still runs, but reach for it only for Go or when migrating old code.

Worked example

The minimal version. No config export, serves at /.netlify/functions/hello.

netlify/functions/hello.mts
import type { Context } from "@netlify/functions"
export default async (req: Request, context: Context) => {
return new Response("Hello, world!")
}

Install types with npm install @netlify/functions (required for TypeScript, optional for JavaScript).

Read environment variables and secrets with Netlify.env.get():

const apiKey = Netlify.env.get("STRIPE_SECRET_KEY")

For the variable to exist at runtime, its scope has to include Functions. Variables set in netlify.toml are not available to functions. Values are frozen per deploy, so change one and redeploy for it to take effect.

Now give it a real URL and read path parameters:

netlify/functions/travel.mts
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",
}

Once config.path is set, the function serves only there, not at /.netlify/functions/travel. path accepts an array for multiple routes and supports URLPattern syntax, so ["/sale/*", "/item/:sku"] works. Named groups land on context.params. For query strings, read req.url.

File locations

The default directory is netlify/functions/, relative to your base directory. Keep it outside your publish directory, or your source files ship as static assets.

A function is either one file or a subdirectory whose entry file is named index or matches the directory name. All three of these create a function called hello:

  • netlify/functions/hello.mts
  • netlify/functions/hello/hello.mts
  • netlify/functions/hello/index.mts

Use .mts or .mjs for ES modules. .cts and .cjs force CommonJS. Plain .ts and .js follow the nearest package.json "type" field.

Picking an execution mode

Synchronous is the default. Up to 60 seconds.

Streaming returns a ReadableStream as the response body. Still 60 seconds, 20 MB response cap. This is the shape for proxying a long AI generation:

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" } })
}

Background for work that outlives a request. The client gets an immediate 202, the return value is discarded, and you get up to 15 minutes. Send results somewhere other than the client:

netlify/functions/process.mts
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" }

On invocation error, Netlify retries after 1 minute, then again 2 minutes later. Payload cap is 256 KB.

Scheduled for cron. Executed in UTC, which is the part that trips people up. Compute the UTC time for the local hour you actually want:

netlify/functions/daily-digest.mts
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
}

Note that this shifts by an hour across daylight saving time, so pick the UTC offset you want. Prefer explicit cron over @daily and @hourly, which can’t target a specific local hour. Scheduled functions are capped at 30 seconds, only fire on published deploys (not previews, where you invoke them with Run now), take no request payload, and can’t stream. schedule is mutually exclusive with path.

Platform events react to deploys, Identity activity, and form submissions. Export a default object with handlers named after the events:

netlify/functions/on-deploy.mts
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}`)
},
}

These always run in the background. Multiple functions can handle the same event and all of them run. Netlify signs each event and verifies it before invoking, so external requests can’t fake one.

Common failure modes

Your function reads a file from disk and throws ENOENT in production. Files you load at runtime with fs.readFile, whether templates, JSON, or WASM, are not bundled. It works under netlify dev because the file is right there. In production it isn’t. Import static data as a module instead. If you genuinely need the file, declare it:

[functions]
included_files = ["files/*.md"]
external_node_modules = ["package-1"]

Your CORS or cache headers do nothing. [[headers]] in netlify.toml, _headers, and redirect header rules apply only to static CDN responses. For a function, set headers in code on the Response you return. (And don’t add CORS headers unless you actually need them.)

context.geo returns the same placeholder every time locally. Under netlify dev, context.geo and context.ip are mocked. Your geo code probably isn’t broken. Exercise the branches with netlify dev --geo=mock --country=DE and confirm on a real deploy.

Your environment variable is undefined. Two likely causes: you set it in netlify.toml (those aren’t available to functions), or its scope doesn’t include Functions. There’s a third, subtler one: the combined env-var limit is about 4 KB for all functions, because they run on AWS Lambda. No Netlify setting raises it. Service-account JSON and PEM keys don’t belong in env vars. Use a bundled file, Blobs, or fetch the value at runtime.

Your scheduled function never fires locally. It won’t. Invoke it once with netlify functions:invoke <name>.

You raised memory and the bill moved. Billing scales linearly with size. Raise memory or vcpu only for known heavy work (AI inference, image or PDF processing, large JSON and CSV) or an observed OOM caused by the function’s own work. Same discipline for region: the cmh default is deliberate. Override it when you have a stated reason like a co-located database or data residency, not on instinct.

Your payload is bigger than you thought. Buffered request and response payloads cap at 6 MB, and binary is Base64-encoded with roughly 30% overhead, giving an effective binary limit near 4.5 MB.

Named imports from a CommonJS package fail. In ES modules, use a default import. Also, __dirname and __filename don’t exist; use import.meta.url.

Reference

config options

OptionTypeNotes
pathstring | string[]Must start with /. Supports URLPattern syntax. Replaces the default URL.
excludedPathstring | string[]Carve out exceptions, e.g. ["/product/*.css"]
methodstring | string[]Restrict HTTP methods
preferStaticbooleanLet a real static file at the URL win
backgroundboolean15-minute limit, immediate 202, no streaming
schedulecron stringUTC. Mutually exclusive with path / excludedPath
rateLimitobject{ action, aggregateBy, to?, windowSize, windowLimit }
memory10244096 MBMutually exclusive with vcpu. Needs credit-based Pro or Enterprise.
vcpu0.52.00.5 maps to 1024 MB, 2.0 to 4096 MB
regionairport codeSelf-serve: cmh, dub, fra, gru, iad, lhr, nrt, pdx, sfo, sin, syd, yul. Support-assisted: cdg, mxp. Needs Pro or Enterprise.

context object

PropertyWhat you get
context.paramsNamed path parameters
context.geocity, country.code/name, latitude, longitude, subdivision, timezone, postalCode
context.ipClient IP string
context.cookiesget(name), set(options), delete(name|options)
context.siteid, name, url
context.deploycontext, id, published, skewProtectionToken
context.account.id, context.server.region, context.requestIdAccount, region, request identifiers
context.waitUntil(promise)Run work after the response is sent. Billing and log duration count until it settles. Functions deployed on or after 2025-03-20.

Outside handler scope, use getContext() from @netlify/functions. It throws outside a request, so wrap it.

Limits (not configurable)

LimitValue
Synchronous execution60s
Scheduled execution30s
Background execution15 min
Buffered request/response6 MB (~4.5 MB effective for binary)
Streamed response20 MB
Background payload256 KB
Combined env vars, all functions~4 KB

Identity event handlers

HandlerCan deny?Can mutate?
userValidateYesYes
userSignupYesYes
userLoginYesYes
userModifiedYesYes
userDeletedNoNo

Call event.deny() to reject (the end user gets a 401, and the first denial aborts the chain). Return { user: {...} } to persist changes, or undefined to pass through.

Deploy events (return void): deployBuilding, deploySucceeded, deployFailed, deployDeleted, deployLocked, deployUnlocked. Form events: formSubmitted, with event.data keyed by field name.

Node runtime

Follows your build’s Node.js version, falling back to Node.js 24. Override with the AWS_LAMBDA_JS_RUNTIME env var (for example nodejs24.x) set through the UI, CLI, or API. Not netlify.toml. Then redeploy.

FAQs

How do I add an API endpoint to a Netlify site? Create netlify/functions/hello.mts exporting a default async handler that takes a Request and returns a Response. It’s live at /.netlify/functions/hello after you deploy. Add export const config = { path: "/api/hello" } for a custom URL.

Where do Netlify Functions live? In netlify/functions/ relative to your base directory, and outside your publish directory. A function can be a single file or a folder whose entry file is named index or matches the folder name.

How do I read environment variables in a Netlify Function? Netlify.env.get("MY_VAR"). The variable’s scope has to include Functions, and it can’t be declared in netlify.toml. Redeploy after changing it.

Why doesn’t my function respect the headers in netlify.toml? Those rules only apply to static CDN responses. Set headers in code on the Response your function returns.

How long can a Netlify Function run? 60 seconds synchronously, 30 for scheduled functions, and up to 15 minutes with config.background: true.

How do I schedule a cron job on Netlify? Export config with a schedule cron expression. It runs in UTC, so convert from your local time (9 AM Eastern is "0 13 * * *"). Scheduled functions only fire on published deploys; use Run now to test.

Why does my function work locally but fail in production with ENOENT? You’re reading a file from disk at runtime, and it isn’t bundled. Import the data as a module, or add it to included_files in netlify.toml.

Can I write Netlify Functions in Go? Yes, using the Lambda-compatible API. Routing, region, and memory get configured in netlify.toml rather than in code.

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 an endpoint to build? Start at netlify.new.