# Netlify config.path vs a netlify.toml redirect: why /api/* 404'd

> A forced /api/* rewrite pointed at /.netlify/functions/api, a URL that stops existing once a v2 function sets config.path. Every data route 404'd in production. The fix was deleting one redirect rule.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-09-21
- Updated: 2026-09-21
- Tags: Netlify, serverless, debugging
- Canonical: https://dil.dev/notes/netlify-config-path-vs-redirect/

## Key takeaways

- A v2 Netlify function with `config.path` is not reachable at `/.netlify/functions/<name>`.
- A forced `/api/*` rewrite to that URL sent every data route to Netlify's own 404 page.
- Read the 404 body: Netlify's HTML page means the request never reached your function.
- Probe production and a known-good deploy before "fixing" anything, and test routing on draft deploys.
- The fix was deleting the redirect: `config.path` already does the routing.

A football club site I built stores its data behind a Netlify function. On 21 September the deploy that was meant to swap placeholder teams for the club's real team list took every data route to a 404 in production. It took about an hour and three quarters from the first broken deploy to the fix, four broken production deploys and two rollbacks. The fix was deleting a redirect that had been added the night before to fix a problem production never had.

The short version: **a Netlify v2 function with `config.path` is not reachable at `/.netlify/functions/<name>`, so a rewrite pointing there is a rewrite to nowhere.**

## The setup

The API is one TypeScript function using the v2 signature, with its route declared in code:

```ts
export default async function handler(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const result = await handleApiRequest({
    method: request.method,
    path: url.pathname,
  // ...
export const config = { path: "/api/*" };
```

The handler routes on the full pathname with an exact-match table:

```ts
"/api/teamo/summary": (p, o) => p.getClubSummary(o),
"/api/teamo/teams": (p, o) => p.getTeams(o),
// ...
const route = routes[req.path];
if (!route) return { status: 404, body: { error: "Not found" } };
```

That had worked in production since the scaffold. The deployed function metadata showed the route Netlify built from `config.path`: pattern `/api/*`, regex `^\/api(?:\/(.*))\/?$`.

## The change that broke it

The night before, during an unrelated analytics change, `/api` calls failed under `vite preview`. That was expected: `vite preview` serves static files and runs no functions. The conclusion drawn was that the single-page app's catch-all was swallowing `/api/*` in production too, and this went into `netlify.toml`:

```toml
[[redirects]]
  from = "/api/*"
  to = "/.netlify/functions/api/:splat"
  status = 200
  force = true
```

No request was made to production to check the premise. Nothing was deployed that night either, so the rule sat on `main` for about 17 hours until the next deploy picked it up.

## What production returned

```text
GET /api/teamo/teams
status 404
content-type: text/html; charset=utf-8
<title>Page not found</title>
```

That body is Netlify's own 404 page, not the handler's. The handler's 404 is JSON: `{"error":"Not found"}` with `application/json`. The request never reached the function.

Netlify's function configuration docs state the mechanism directly: when you set a custom `path`, "the function is only available at that path, not at the default `/.netlify/functions/<name>` URL." The forced 200 rewrite sent every `/api/*` request to that default URL, which no longer existed.

One detail worth knowing: Netlify's documented request chain matches functions on their configured path before it applies redirects. In practice, a `force = true` rewrite on the same prefix still won. I would not rely on the documented order to protect you from a rule like this one.

## The debugging, including the wrong turns

| Step | Theory | Result |
|---|---|---|
| 1 | Cached function: redeploy with `--skip-functions-cache` | 404 |
| 2 | `--dir` deploys ignore `netlify.toml`: add the same rewrite to `public/_redirects` | 404 (the same bug, twice) |
| 3 | Control: request the last good deploy | 200 JSON |
| 4 | Roll back production | 200 |
| 5 | CLI bundling broke imports: `netlify deploy --prod --build` | 404, second rollback |
| 6 | Read `api.ts` and `netlify.toml` side by side: redirect vs `config.path` | the real cause |
| 7 | Draft deploy of the same tree minus the redirect | 200 JSON, real teams |

Step 3 was the turning point and it came too late. The last good deploy predated the redirect by about 12 hours, and checking its timestamp against `git log` would have pointed straight at the one routing change in between. Instead I assumed the good deploy already included the redirect, which made the redirect look innocent.

## The fix

Delete the rule. `netlify.toml` keeps only the SPA fallback, which is safe because functions with `config.path` are matched before it:

```toml
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
```

It was verified on a draft deploy first (`/api/teamo/teams` returned 200 `application/json` with the five real teams), then on production: six `/api/teamo/*` routes and five pages returned 200, and a Playwright check rendered the team list with no JavaScript errors.

## What I'd do differently

- **Probe production before fixing production.** The redirect fixed a failure that only existed in `vite preview`. One `curl` against the live site would have shown `/api/teamo/teams` returning JSON and ended the idea.
- **One routing mechanism per route.** With v2 functions, `config.path` is the routing. If a route needs a rewrite, it should point at the public path, never at `/.netlify/functions/`.
- **Read the 404 body before theorising.** HTML with Netlify's page versus the handler's JSON tells you which layer answered. That distinction was available in the first minute.
- **Run the known-good control first, and diff it.** Comparing against the last good deploy settled the question. Doing it first, and diffing the configuration between the two deploys, would have saved most of the hour.
- **Test routing on draft deploys only.** `netlify deploy --build` without `--prod` gives a real URL with real functions. Four theories were tested on production, and each one was an outage.
- **Keep infrastructure out of feature commits.** The redirect arrived inside a large analytics commit, which is why nobody reviewing that change looked at routing.
- **Fix the comments too.** The function's docblock still says "Netlify redirects /api/* here (see netlify.toml)". It has been wrong since the fix.

## Quick answers

**Why does my Netlify function 404 after adding a redirect to /.netlify/functions?**

If the function exports config with a custom path, Netlify serves it only at that path. The default /.netlify/functions/<name> URL no longer exists, so a redirect or rewrite that targets it sends every request to a 404.

**Do I need a netlify.toml redirect for a v2 function with config.path?**

No. A function that exports config = { path: '/api/*' } is routed by Netlify directly and receives the full original path, such as /api/teams. Adding a rewrite for the same prefix is at best redundant and, if it points at the default function URL, breaks it.

**How can I tell whether a Netlify 404 came from my function or from Netlify?**

Read the response body and content type. A 404 from your handler looks like whatever your handler returns, for example JSON. A 404 with text/html and Netlify's own Page not found page means the request never reached your function.

**How do I test a Netlify routing change safely?**

Deploy a draft with netlify deploy --build (without --prod) and curl the draft URL. Compare the status, content type and body against a known-good deploy before touching production.
