You spent hours setting up a Netlify function. It runs perfectly on your machine with netlify dev, but after you deploy it, the live URL returns a 404 with no useful explanation. Many tutorials still use the legacy API, so their event handler examples can lead you astray when you need modern routing.
- V2 syntax: Uses standard web
RequestandResponseobjects - Routing: Defined in an exported
configobject - Default directory:
netlify/functions/(configurable innetlify.toml) - Local dev: Run
netlify devto test with Netlify's local development environment - Sync timeout: 10 seconds on the free tier
Legacy vs. Modern API: What Changed
For years, Netlify functions used AWS Lambda-style syntax. You exported a handler that received an event and returned an object with a statusCode and a stringified body. It worked, but it was different from the APIs used elsewhere on the web.
The modern API uses the standard Request and Response objects. Your serverless function can now look much like code written for Edge functions or modern full-stack frameworks.
There's no need to parse a JSON string from an event body or remember a Netlify-specific response format. Just return a standard Response.json().
Setting Up Your First Modern Netlify Function
With the modern API, you can give a function a clean, custom URL directly in its file instead of relying on the old /.netlify/functions/your-function URL structure.
Prerequisites and Local Environment
Start by creating a netlify/functions directory at the root of your project. If you use another folder, specify it in netlify.toml under the [build] section.
If you haven't installed the Netlify CLI, run npm install -g netlify-cli, then authenticate with ntl login. The CLI runs on Node, so check that you're using a current Node.js version before you begin. Older releases may fail to bundle functions that use the newer syntax.
During development, run your site with netlify dev. It starts a local server that applies Netlify's routing, environment variables, and proxy rules.
Using the Web Request and Response Objects
Create hello.mts (or .ts / .js) in your functions directory. Here's the syntax for a basic endpoint:
import type { Context } from "@netlify/functions"
export default async (req: Request, context: Context) => {
const url = new URL(req.url)
const name = url.searchParams.get("name") || "World"
return new Response(`Hello, ${name}!`, {
status: 200,
headers: { "Content-Type": "text/plain" }
})
}
The function reads query parameters through the standard URL interface and returns a native Response.
Defining Custom Routes with the Config Export
With the legacy API, mapping a function to a URL such as /api/hello meant configuring a _redirects file. The V2 API lets you define the route in the function file itself.
Export a config object with the path:
import type { Config } from "@netlify/functions"
export default async (req: Request) => {
return Response.json({ message: "System online" })
}
export const config: Config = {
path: "/api/status"
}
After deployment, Netlify routes requests to /api/status to your function.
Real-World Serverless Use Cases
A function can do much more than return "Hello World." It exposes a public endpoint, so the same REST API design best practices that guide other backends apply here. Here's how the modern syntax handles common production tasks.
Hiding API Keys via Proxy
If you call a paid service, such as ElevenLabs for text-to-speech or a translation API, keep its API key out of your frontend code. A serverless function can handle the request on behalf of your frontend.
Your frontend, regardless of the React build tool or framework you use, sends a request to the Netlify function. The function reads the required environment variables and forwards the request to the external API.
import type { Config } from "@netlify/functions"
export default async (req: Request) => {
// The frontend only sees this function, not your actual API key
const requestData = await req.json()
const response = await fetch("https://api.elevenlabs.io/v1/text-to-speech", {
method: "POST",
headers: {
"xi-api-key": process.env.ELEVENLABS_SECRET_KEY!,
"Content-Type": "application/json"
},
body: JSON.stringify(requestData)
})
const audioBuffer = await response.arrayBuffer()
return new Response(audioBuffer, {
headers: { "Content-Type": "audio/mpeg" }
})
}
export const config: Config = { path: "/api/synthesize" }
Handling Webhooks
E-commerce stores and SaaS products often need to process payment events or deliver digital products as soon as a webhook arrives. A serverless function can receive the webhook, validate its payload, and trigger other services without a dedicated Express server.
Set the webhook URL in the sending platform's dashboard to your custom function path. The function runs when an event arrives and processes the data, with no server for you to maintain. Before acting on an event, read the raw request body and verify the sender's signature. Most providers send an HMAC signature header generated with a shared secret; checking it helps prevent someone from forging a payment event through your public endpoint.
Troubleshooting: Why Does It 404 in Production?
Your site works with netlify dev, but the live URL says Not Found. Work through these checks to find out why.
The Functions Directory Isn't Configured
If your code isn't in the default netlify/functions folder, tell Netlify where to find it. For example, a custom directory such as src/api needs to be configured explicitly.
Create or update netlify.toml at the root of your repository:
[build]
functions = "src/api"
Check that the functions directory is committed to Git, too. It may have been included in .gitignore by mistake.
Missing CORS and OPTIONS Preflight Headers
If your frontend and Netlify backend are on different domains, the browser may block requests under its CORS (Cross-Origin Resource Sharing) policy. For some POST requests, the browser first sends an OPTIONS preflight request to check what's allowed.
If your function doesn't handle that OPTIONS request, the browser stops before sending the POST. The resulting network error can be mistaken for a 404.
export default async (req: Request) => {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS"
}
// Intercept the preflight request immediately
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers })
}
return Response.json({ success: true }, { headers })
}
Environment Variable Deployment Quirks
A missing production environment variable can also cause a function to fail. process.env.MY_KEY may work locally because it reads your .env file, while production doesn't read committed .env files for security reasons.
Add the variables in the Netlify dashboard under Site Settings, then Environment Variables.
After changing a variable in the dashboard, trigger a new deployment by pushing a commit or selecting "Trigger deploy." The change won't necessarily reach the live function until you redeploy.
The 404 Diagnosis Table
If the live URL fails, check these signs in order. They cover common reasons a function works locally but fails in production.
| Symptom | Likely cause | One-line fix |
|---|---|---|
| Deploy log shows 0 functions packaged | Wrong functions directory | Set [build] functions = "your/dir" in netlify.toml |
| Browser shows a CORS or network error on POST | No OPTIONS preflight handler |
Return 204 with Access-Control-* headers |
Function runs but a value is undefined |
Env var set but not deployed | Add it in the dashboard, then redeploy |
Deploying to Netlify Production
Once local testing is complete and your environment variables are set in the dashboard, push your code to your connected Git repository on GitHub, GitLab, or Bitbucket.
Netlify detects the functions directory, bundles dependencies, reads the config path exports, and deploys the endpoints. Check the deploy log for the number of functions packaged and deployed.
Keep the execution limit in mind once your endpoints are live. Synchronous functions have 10 seconds on the free tier. For longer work, such as converting a large file or waiting on a slow external service, use a background function by adding a -background suffix to the filename so the job can continue beyond that limit.
