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:
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:
"/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:
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/api/:splat"
status = 200
force = trueNo 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
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:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200It 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. Onecurlagainst the live site would have shown/api/teamo/teamsreturning JSON and ended the idea. - One routing mechanism per route. With v2 functions,
config.pathis 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 --buildwithout--prodgives 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.




