You just spent hours configuring a Netlify function, it runs perfectly on your local machine using netlify dev, but the moment you push to production, you are hit with a silent, unforgiving 404 error. Most tutorials online are still teaching the deprecated legacy API, leaving you with outdated event handler code that simply breaks when faced with modern routing expectations.

  • V2 syntax: Uses the standard web Request and Response objects
  • Routing: Defined explicitly via an exported config object
  • Default directory: netlify/functions/ (configurable in netlify.toml)
  • Local dev: Run netlify dev to simulate the production environment locally
  • Sync timeout: 10 seconds on the free tier

Legacy vs. Modern API: What Changed

For years, developers built Netlify functions using an AWS Lambda-style syntax. You exported a handler that received an event and returned an object containing a statusCode and a stringified body. It worked, but it felt disconnected from modern web standards.

The current API replaces that clunky syntax completely. Netlify now uses the global Request and Response objects. This means the code you write for a serverless function now looks identical to the code you would write for Edge functions or modern full-stack frameworks.

You no longer need to manually parse stringified JSON from an event body or memorize custom Netlify response structures. You just return a standard Response.json().

Setting Up Your First Modern Netlify Function

Forget the old /.netlify/functions/your-function URL structure. The modern API lets you mount functions on clean, custom URLs directly from the file itself.

Prerequisites and Local Environment

First, make sure your project structure is ready. Create a directory named netlify/functions at the root of your project. If you prefer a different folder name, you must specify it in your netlify.toml file under the [build] section.

Next, install the Netlify CLI if you haven't already. Run npm install -g netlify-cli and authenticate using ntl login. The CLI runs on Node, so confirm you are on a current Node.js version before you start, since older releases fail to bundle the newer function syntax.

To test your functions during development, always run your site using netlify dev. This command spins up a local server that perfectly mimics Netlify's production routing, environment variables, and proxy rules.

Using the Web Request and Response Objects

Create a new file named hello.mts (or .ts / .js) inside your functions directory. Here is the exact syntax you should use 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" }
  })
}

Notice how clean this is. You are simply extracting query parameters using the standard URL interface and returning a native Response.

Defining Custom Routes with the Config Export

In the legacy API, mapping a function to a clean URL like /api/hello required setting up a complicated _redirects file. The V2 API handles this natively within the function file.

You simply export a config object that defines 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"
}

When you deploy this, Netlify automatically intercepts requests to /api/status and triggers your function.

Real-World Serverless Use Cases

Tutorials often stop at "Hello World," but serverless functions are meant for heavy lifting. Because a function exposes a public endpoint, the same REST API design best practices that apply to any backend apply here too. Let's look at how this modern syntax handles actual production demands.

Hiding API Keys via Proxy

When you call a paid service, like passing text to a text-to-speech API such as ElevenLabs or hitting a translation endpoint, you can never expose those API keys in your frontend code. A serverless function acts as the perfect invisible middleman.

Your frontend, whatever React build tool or framework you chose, sends a simple, unauthenticated request to your Netlify function. The function then securely injects your 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

If you run an e-commerce store or SaaS product, automated digital delivery and payment events require instant webhook processing. Serverless functions can securely catch these incoming webhooks, validate the payload, and trigger external automations without spinning up a dedicated Express server.

You simply set the webhook URL in the sending platform's dashboard to your custom Netlify function path. The function wakes up, processes the incoming data, and goes right back to sleep, costing you zero server maintenance. One thing to lock down before you trust it: read the raw request body and verify the sender's signature (most providers send an HMAC signature header signed with a shared secret) so nobody can forge a fake payment event by hitting your public endpoint.

Troubleshooting: Why Does It 404 in Production?

This is the most critical hurdle. You run netlify dev, everything is green, but the live URL returns a frustrating Not Found. Here is how you diagnose and fix the root cause.

The Functions Directory Isn't Configured

If you placed your code anywhere other than the default netlify/functions folder, Netlify's build system won't know it exists. If you used a custom folder like src/api, you must explicitly tell Netlify where to look.

Create or update the netlify.toml file at the root of your repository:

[build]
  functions = "src/api"

Also verify that your functions folder is actually committed to Git. A common mistake is accidentally including the functions directory in a .gitignore file.

Missing CORS and OPTIONS Preflight Headers

If your frontend is hosted on a different domain than your Netlify backend, browsers will block the request citing CORS (Cross-Origin Resource Sharing) policy. Before a browser sends a POST request, it automatically fires an OPTIONS preflight request to check permissions.

If your function doesn't explicitly handle that OPTIONS request, the browser aborts and throws a network error that often looks like 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

Your function might be crashing silently because an environment variable is missing in production. process.env.MY_KEY works locally because it reads your .env file, but Netlify production does not read committed .env files for security reasons.

You must manually enter these variables into the Netlify dashboard under Site Settings, then Environment Variables.

Here is the catch: changing an environment variable in the dashboard does not update live functions instantly. You must trigger a fresh deployment (usually by pushing a new Git commit or clicking "Trigger deploy" in the dashboard) for the build system to bake those new variables into your function's runtime.

The 404 Diagnosis Table

When the live URL fails, work through these three checks in order. Nearly every "works locally, 404 in production" report traces back to one of them.

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 your local testing is complete and your environment variables are set in the dashboard, deployment requires zero configuration. You simply push your code to your connected Git repository (GitHub, GitLab, or Bitbucket).

Netlify automatically detects the functions directory, bundles the dependencies, reads your config path exports, and deploys the endpoints to their global edge network. Check your Netlify deploy log; you should clearly see a line stating how many serverless functions were successfully packaged and deployed.

Once your endpoints are live, the next limit you will hit is time. Synchronous functions get 10 seconds on the free tier, so if you are doing something heavy like a large file conversion or a slow third-party call, convert it to a background function (name the file with a -background suffix) and let it run past that ceiling instead of timing out mid-request.