©Code byMilanParmar

Lovable edge function not working after publish: the fix

An error-string table to find your cause in a minute, what the two generic messages ("returned a non-2xx status code" and "Failed to send a request") hide and the three lines of code that show the real error, then the six causes with their fixes: secrets missing from the published backend, CORS and the OPTIONS preflight, a 401 from JWT checks, a function deployed to the wrong project, Supabase's time and memory limits, and import errors that only show in the logs. Plus how to read the logs and two curl commands to test with.

Updated 19 min read

An AA battery lying on its side on a desk

A Lovable edge function not working after publish fails for one of three reasons, and the error name tells you which. A FunctionsHttpError means the function ran and returned an error, so read its logs. A FunctionsFetchError means the browser never reached it: CORS, a wrong URL, or a paused project. A FunctionsRelayErrormeans Supabase couldn't start it.

An edge function is the small piece of server code a Lovable app uses for anything that can't happen in the browser: calling an AI model, sending an email, talking to Stripe. It runs on Supabase, either inside Lovable Cloud or in your own Supabase project. The preview and the published app call the same function, which is why this is confusing: the code didn't change, the surroundings did. This guide goes from the error line to the setting that caused it. It's written for the founder, with enough detail for whoever helps them.

Which edge function error do you have?

Open the live app in Chrome, press F12, open the Console tab, and do the thing that fails. Then find the line that mentions the function. Supabase's client throws one of three error classes, and its reference docs show how to tell them apart with instanceof. The class is the fastest diagnosis you'll get.

Edge function error strings in a Lovable app, what each means, and where to look
What the console saysWhat it meansRead
FunctionsHttpError, "Edge Function returned a non-2xx status code"Your code ran and returned 4xx or 5xx. The real message is in the response body and the function logsCauses 1, 3, 5; then the logs section
FunctionsFetchError, "Failed to send a request to the Edge Function"The browser never got a usable response: CORS blocked it, the URL is wrong, or the project is pausedCauses 2 and 4
FunctionsRelayErrorSupabase accepted the request but couldn't run the function: it isn't deployed or failed to startCauses 4 and 6
"blocked by CORS policy", "No 'Access-Control-Allow-Origin' header" The preflight request wasn't answered, or an error response went out without CORS headersCause 2
401, "Missing authorization header" or "Invalid JWT"Supabase rejected the call before your code ranCause 3
404No function with that name on that projectCause 4
504 Gateway TimeoutThe function took longer than 150 seconds to answerCause 5
500 with nothing useful in the browserA missing secret, a crash, or an import that failed. The logs have the lineCauses 1 and 6

One thing to know before you start: the browser only shows a status code. The message your function wrote, like "OPENAI_API_KEY is not set", is in the response body and in the logs. Supabase's docs read it from a FunctionsHttpError with await error.context.json(), so if your app only logs error.message, you're debugging blind.

Edge Function returned a non-2xx status code: the real error

This line is not your function's error. It's the fixed text the Supabase client uses for every response outside 200 to 299. Your function may have written "OPENAI_API_KEY is not set" or "Missing authorization header" in its reply, and the client replaced it with this one sentence. That behaviour has been an open issue on the functions-js repository since April 2023, and the repository was archived in January 2026, so the message isn't going to get better. What you can do is read the response the client kept. It's on error.context, with the status code and the body.

Where the app calls the function
import { FunctionsHttpError } from "@supabase/supabase-js";

const { data, error } = await supabase.functions.invoke("ask-ai", {
  body: { prompt },
});

if (error instanceof FunctionsHttpError) {
  const status = error.context.status;
  const body = await error.context.text(); // .json() if the function returns JSON
  console.error(`ask-ai failed with ${status}:`, body);
}

Three lines, and the console goes from "something failed" to "401, Missing authorization header" or "500, OPENAI_API_KEY is not set". If you don't edit code yourself, this is the change to ask for, in these words, before asking for any fix:

The last sentence matters. When the status is 401, the fastest change a tool can make is to turn JWT verification off for every function. The error disappears and every function is now open to anyone on the internet. If a fix attempt did that, it belongs in the same category as Lovable breaking the same feature on every fix: roll it back and restate the problem with the status and body you now have. Once you have them, this table says which cause you're looking at. Supabase's status codes page gives each code a platform meaning, and the right column maps it onto this guide.

Status codes behind the non-2xx message, what Supabase says each means, and the cause to read
StatusWhat Supabase says it meansCause
400 or 422Your own code returned it, usually a missing or malformed field in the request. The body says whichRead the body. Often a front-end change renamed a field
401JWT verification is on and the token is missing, invalid, or expiredCause 3
404No function by that name on that project, or the URL path is wrongCause 4
405An HTTP method the function doesn't acceptThe app sends GET to a function written for POST, or the reverse
500The function threw an uncaught exception, logged as WORKER_ERRORCauses 1 and 6, then the logs
503The function failed to start, logged as BOOT_ERROR: a syntax error, a bad import, or the platform itselfCause 6
504No response within the 150-second request timeoutCause 5
546Stopped for exceeding a resource limit, logged as WORKER_RESOURCE_LIMIT: 2 seconds of CPU time or the memory capCause 5

A 503 deserves one extra check. Supabase's 503 page lists three sources: a boot failure, a 503 your own code returned (for example, passing through a provider's outage), or a platform problem. If the response body contains BOOT_ERROR, it's the first. If every function on the project started failing at the same minute with nothing in your logs, check Supabase's status page before touching code.

Failed to send a request to the Edge Function: what it means

This is the other generic line, a FunctionsFetchError. It means the browser never received a response it was allowed to read, so your function's code probably never ran. Three situations produce it, and one of them is disguised. Supabase's 404 troubleshooting page says browsers misreport a 404 as a CORS error, and the JavaScript client then shows only "Failed to send a request to the Edge Function". So a function that doesn't exist on this project and a function with a CORS bug look identical in the console.

  1. CORS blocked it. The OPTIONSpreflight wasn't answered, or the response came back without the Access-Control-Allow-Origin header. Cause 2.
  2. The function isn't there.Supabase's 404 page names two platform codes: NOT_FOUND, the name isn't recognised at all, and NOT_FOUND_FUNCTION_BLOB, the name is known but the deployed bundle is missing, which can happen while the dashboard still shows the function as active. Cause 4.
  3. Nothing answered.The project is paused, or the browser is offline. Every function fails at once and the Network tab shows the request as failed rather than as a status code. When the whole app stops reading data too, it's a broken Supabase connection, not a function problem.

To tell them apart, open Chrome's Network tab (F12, then Network), filter on functions/v1, and trigger the failure. Click the red row. A 404 in the Status column is situation two. A status of "CORS error" or "(failed)" with an OPTIONS row above it that has no access-control-allow-origin header is situation one. No row at all, or one that stays pending, is situation three. The preflight curl commandfurther down gives the same answer from outside the browser. For situation two, Supabase's page says to check the name for typos, including capitalisation and a long dash typed in place of a hyphen, then redeploy the function, and to contact its support if a redeploy doesn't clear it.

Cause 1: secrets not set in the published backend

The most common cause, and the one with the vaguest symptom. The function needs an API key, reads it with Deno.env.get("OPENAI_API_KEY"), gets nothing, and either crashes with a 500 or sends an empty key to the provider and gets a 401 back from them. The browser shows a FunctionsHttpError and nothing else.

Where the key has to live depends on which backend the app uses. Lovable's Cloud docs describe two setups: Lovable's own hosted backend, which it provisions for you, and an external backend you connect yourself, such as your own Supabase project. They're separate places with separate secrets, and Lovable's docs say it doesn't move a project from one to the other in one step.

On Lovable Cloud

Secrets go in Lovable's Secrets manager. Lovable's secrets page says this manager is for backend values only, such as Stripe or Resend keys, that functions read them at runtime through Deno.env.get, and that once saved a value can never be viewed again, only replaced. Two things catch people. First, a value with a VITE_ prefix belongs in the project's .envfile and ships to the browser, so it isn't a secret at all and a function can't read it. If your AI key is in .envwith that prefix, it's both missing from the function and public, which is the situation in what to do when a Lovable API key is exposed. Second, the same page says only the names of your secrets are copied when you remix a project, not the values, so a remixed app starts with every key missing.

On your own Supabase project

Secrets go in the Supabase dashboard under Edge Functions, then Secrets, or from a terminal with supabase secrets set NAME=value. Supabase's guide says functions read a new secret immediately, with no redeploy. The guide also lists two rules worth checking: a secret name can't start with SUPABASE_, because that prefix is reserved and the dashboard rejects it, and only project Owners and Administrators can create or delete production secrets. If a developer added the key and it didn't stick, that's why.

The check is the same in both setups: list the secrets, and compare each name letter for letter with the name the code asks for. AI-generated code renames things between prompts. A function that reads OPENAI_KEY while the secret is called OPENAI_API_KEY fails exactly like a missing key.

Cause 2: CORS error and the OPTIONS preflight

CORS is the browser's rule that a page on one domain can't call a server on another unless the server says it's allowed. Your published app is on yourapp.com and the function is on yourref.supabase.co, so every call is cross-domain. Before the real request, the browser sends a small OPTIONSrequest asking for permission. Supabase's CORS guide says functions must handle that preflight, and its troubleshooting page lists the three usual failures: a missing Access-Control-Allow-Origin header, an unhandled OPTIONS request, and mismatched allowed methods or headers.

This shows up after publishing for a plain reason: the function was only ever tested from the preview, and the generated code answered the preflight for some paths and not others. The handler needs to return the headers on the OPTIONS request, on every success, and on every error. The last one is what most generated code misses. When an error goes out without the headers, the browser reports a CORS failure instead of the real message, which is why a missing secret sometimes looks like a CORS problem.

supabase/functions/ask-ai/index.ts
import { corsHeaders } from "npm:@supabase/supabase-js@^2/cors";

Deno.serve(async (req) => {
  // 1. Answer the preflight with the headers and nothing else.
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }

  try {
    const { prompt } = await req.json();
    const key = Deno.env.get("OPENAI_API_KEY");
    if (!key) throw new Error("OPENAI_API_KEY is not set on this project");

    const answer = await askModel(prompt, key);

    // 2. Headers on the success response.
    return Response.json({ answer }, { headers: corsHeaders });
  } catch (err) {
    // 3. Headers on the error response too, or the browser hides the message.
    return Response.json(
      { error: (err as Error).message },
      { status: 400, headers: corsHeaders },
    );
  }
});

Supabase's guide says the corsHeaders import exists from supabase-js 2.95.0 and keeps the allowed-headers list in step with what the client sends. On older versions it shows the same headers hardcoded in a shared _shared/cors.ts file, with Access-Control-Allow-Origin: *, the methods list, and the allowed headers including authorization, x-client-info, apikey, and content-type. If your copy of that file is older and the client has started sending a header it doesn't list, the preflight fails for that header alone.

Cause 3: 401 from JWT verification

By default Supabase checks every call to an edge function for a valid login token (a JWT) in the Authorization header before your code runs. Its troubleshooting page lists the three ways this fails: an invalid or expired token, a missing Authorization header, and a user without the right permissions. When the check fails you get a 401, and the function logs show nothing, because the function never ran.

In a Lovable app this appears after publishing in two ways. A page that calls the function for visitors who aren't logged in: in the preview you were always logged in, so the token was always there. Or code that calls the function with plain fetch instead of supabase.functions.invoke, so no header is sent at all. The Supabase client adds the apikeyheader and the user's session token for you, which is the reason to use it.

For a function that should be public, or one called by another service that has no Supabase login, turn the check off for that function only, and do your own check inside the code:

supabase/config.toml
[functions.ask-ai]
verify_jwt = false

Put it in the config file rather than only in the dashboard, because a deploy from the repo can overwrite a dashboard setting. The Stripe webhook guide covers that case in detail, since Stripe never sends a login token and the webhook function has to verify Stripe's signature instead. The rule is the same for any function: turning the platform check off is fine only when the function checks something else.

Cause 4: function not deployed, or on the wrong project

Supabase's troubleshooting page puts three network causes together: the function isn't deployed, the URL is wrong, or there's a connectivity problem. In a Lovable app the first two have one root: the app and the function point at different places.

On Lovable Cloud, the edge functions page says Lovable writes and deploys functions when you describe a feature in chat, and the Cloud tab lists each function with its status, invocation count, and success rate. If the function you expect isn't in that list, it was never deployed, however much code for it exists in the project. The same page says functions are unavailable while a project is paused, which is why everything backend can stop at once.

On your own Supabase project, the function has to be deployed to the project the published app talks to. Open the Edge Functions list in the Supabase dashboard and confirm the function is there. Then compare the project reference in the app's VITE_SUPABASE_URL (the part before .supabase.co) with the reference in the dashboard address bar. They drift apart when someone creates a second project for production, when a developer deploys from a laptop logged into a different account, or part way through moving a Lovable app to your own hosting, when the front end has moved and the functions haven't. A 404 with the function's exact name in the URL is this cause until proven otherwise.

Cause 5: timeouts, cold starts, and compute limits

Supabase's limits page gives the numbers. A request that doesn't get a response within 150 seconds returns a 504 Gateway Timeout to the caller. Each request gets 2 seconds of CPU time, counting only work on the processor, not waiting on a network call. Memory is 256MB. A worker stays alive for 150 seconds on the Free plan and 400 seconds on paid plans, serving more than one request while it lasts.

The AI feature is where these bite. A function that calls a model, waits for the whole answer, and then replies can pass 150 seconds on a long prompt. The fix is to stream the response back as it arrives, or to accept the job, return immediately, and let the app poll for the result. CPU time catches a different pattern: code that parses a big PDF, resizes images, or hashes a large file inside the function. Two seconds is enough for most logic and not enough for that, and the error is a 546, and Supabase's page for that code says the logs will show either "CPU Time exceeded" or "Memory limit exceeded" to tell you which cap was hit.

Cold starts are the slow first call after a quiet spell, while Supabase starts a fresh worker. In the preview you were calling the function all afternoon, so it was always warm. The first customer of the morning gets the cold one. If the app's own timeout is shorter than that start, the call looks failed when it isn't. Set the client's timeout above a few seconds, and show a loading state rather than an error on the first attempt.

Cause 6: import errors that only show in the logs

A function that fails to start never answers a request, so the browser sees a relay error or a generic failure and nothing explains it. The explanation is in the Logs tab, which Supabase's troubleshooting page says shows platform events, including deployments and errors. The usual lines are a module that couldn't be fetched, a package that doesn't run on Deno, or a syntax error in a file the function imports.

AI tools are a frequent source of this. A prompt asks for an email feature, the tool imports a Node-only package, and the function deploys but can't boot. Or a shared file is edited for one function and breaks every function that imports it, which is why three features can fail from one change. The limits page adds a size rule: a bundle can be 20MB when built locally with the CLI and 5MB when built server-side through the dashboard, so a function that pulls in a large library can fail to deploy at all through one path and work through the other.

The fix is to read the first error line in the deploy log, swap the package for one that supports Deno (most packages on npm do, imported with the npm: prefix), and deploy again. If the function worked last week and the only change was a Lovable edit, roll that edit back first and confirm it boots, then ask for the change again with the error text in the prompt.

How to read Supabase edge function logs

Every cause above leaves a line in the logs, so this is the step to do before changing settings. Supabase's troubleshooting page describes two tabs on each function. Invocations shows the request and response for each execution, which is where you see the status code, how long it took, and the body the function sent back. Logs shows platform events, including deployments and errors, and the output of every console.log and console.errorin the code. In the current dashboard those two tabs are merged: Supabase's logging page says the Logs view shows invocations and log events in one list, and the separate Invocations and Logs tabs appear only if you turn the updated interface off. The platform's own lines carry the event names from the status table: WORKER_ERROR for an uncaught exception (a 500), BOOT_ERRORfor a function that couldn't start (a 503), and WORKER_RESOURCE_LIMIT for one that hit the CPU or memory cap (a 546). The logging page also says uncaught exceptions are logged automatically with their stack trace, so a 500 always has a line here even when the code has no logging of its own.

  1. On your own Supabase project, open the dashboard, then Edge Functions, then the function, then Invocations. On Lovable Cloud, Lovable's edge functions page says the list under More, Database, Manage, Edge functions shows each function with its status, invocation count, and success rate, and a View logs option that filters the Logs view to that function.
  2. Trigger the failure once from the live app, then refresh the list. If nothing appears, the request never reached the function: that is a CORS, 401, 404, or wrong-project cause, not a code cause.
  3. If a row appears with a 4xx or 5xx, open it and read the response body. That is the message your code wrote, or the uncaught error.
  4. Switch to Logs and read the lines around the same time. A missing secret, a failed import, or a provider's error response is here, usually in the first console.error line.
  5. Paste that one line, not a screenshot of the browser, into Lovable or to whoever is fixing it. The browser line says something broke. The log line says what.

How to test an edge function with curl

The fastest way to separate "the function is broken" from "the app calls it wrong" is to call it from outside the app. curlruns in the Terminal on a Mac or in PowerShell on Windows. You need the project URL and the anon (publishable) key, both of which are already in the app's .env and safe to use here. Two commands cover most of this guide.

1. The preflight: does the function answer OPTIONS?
curl -i -X OPTIONS "https://YOURREF.supabase.co/functions/v1/ask-ai" \
  -H "Origin: https://yourapp.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization, content-type"

You want a 2xx with access-control-allow-originin the headers. A 404 is cause 4. A 2xx without that header, or a 401, is cause 2: the function isn't handling OPTIONS before anything else.

2. The real call, with the anon key
curl -i -X POST "https://YOURREF.supabase.co/functions/v1/ask-ai" \
  -H "Authorization: Bearer YOUR_ANON_KEY" \
  -H "apikey: YOUR_ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"hello"}'

A 401 here with "Missing authorization header" or "Invalid JWT" is cause 3. A 500 with a message in the body is cause 1 or 6, and the message tells you which. A 504 after a long wait is cause 5. A 200 with the right answer means the function is fine and the problem is how the app calls it: the wrong URL, a missing header, or a CORS header the app sends that the function doesn't allow. Note that this test uses the anon key, so a function that reads the logged-in user from the token will see no user. That's correct for a public function and the expected failure for a private one.

Why it worked in preview and fails after publishing

Nothing about the function's code changes when you press Publish. What changes is everything around the call, and each change maps to one cause above. This is the table to work through when the error class alone doesn't settle it.

What differs between the Lovable preview and the published app, and which cause it triggers
In the previewAfter publishingCause
Page runs on a Lovable preview addressPage runs on your custom domain, a new origin2: CORS
You are always logged inVisitors and new sign-ups call it logged out3: 401
Function called every few minutesQuiet overnight, cold start in the morning5: timeouts
Secrets added on the project you built inLive app points at a second project, or a remix1 and 4: secrets, wrong project
Short test prompts and small filesReal prompts, real PDFs, real images5: CPU and memory limits
Last edit tested on one functionA shared file broke three others6: boot errors
Free project in daily useFree project paused after inactivity4: nothing deployed answers

When to get help with Lovable edge functions

If the error table pointed at one cause and the curl test confirmed it, the fix is a setting or a few lines, and you're done. Get help when more than one function is failing, when the fix has to touch a shared file that every function imports, when the function moves money or sends email to customers, or when you've been through three Lovable fix attempts and it still fails. The backend is where generated code is weakest, and it's where mistakes cost the most. On The Chat Company, an AI-generated demo I took over, I kept about 20% of the code and rewrote the rest before it could serve real businesses. Nine weeks later it was a SaaS used by 30+ businesses, and it now handles 100K+ messages a day.

Secret keys, logins, and Stripe webhooks are three of the six areas I check in a Launch-Ready Review: a fixed-price check of a vibe-coded app for $900–1,500 in three to five days, with a written report and a priced fix list. If the list is long, fixing it is a Production Sprint for $3,000–7,500, and the review fee comes off that price. Before launch, the free vibe-coded app launch checklist covers secret keys and Stripe webhooks alongside data access and backups. Either way, send me the app's linkand the error line from the console or the logs, and I'll tell you which cause it is.

Sources

  1. Error handling: FunctionsHttpError, FunctionsRelayError, FunctionsFetchError, Supabase Docs
  2. Non-200 status responses do not expose message (functions-js issue 55, open since April 2023), GitHub, supabase/functions-js
  3. Edge Function status codes: 401, 404, 405, 500 WORKER_ERROR, 503 BOOT_ERROR, 504, 546, Supabase Docs
  4. Edge Function 404 error response: browsers misreport 404s as CORS errors, Supabase Docs
  5. Edge Function 503 error response: boot error, your code, or the platform, Supabase Docs
  6. 546 - WORKER_RESOURCE_LIMIT Exceeded: CPU or memory limit hit, Supabase Docs
  7. Edge Function logging: uncaught exceptions, boot and shutdown events, the 10,000 character limit, Supabase Docs
  8. Unable to call Edge Function: CORS, authentication, network, and logs, Supabase Docs
  9. CORS in Edge Functions: handling the OPTIONS preflight and the shared headers, Supabase Docs
  10. Edge Function secrets: the dashboard, the CLI, default variables, and no redeploy, Supabase Docs
  11. Edge Function limits: 150s idle timeout, 2s CPU, 256MB, bundle size, Supabase Docs
  12. Lovable Cloud: edge functions, secrets, logs, and your own Supabase project, Lovable Docs
  13. Edge functions on Lovable Cloud: status, invocations, View logs, and paused projects, Lovable Docs
  14. Secrets on Lovable Cloud: backend values only, VITE_ in .env, not copied on remix, Lovable Docs
Milan Parmar

The developer

Freelance full-stack developer in Bengaluru with 5+ years of experience. I redesign websites, take over broken apps, and build SaaS products, with 18 case studies to show for it.

FAQs

Why does the edge function work when I'm logged in but fail for visitors?

Because the function checks for a login token before it runs. When you're signed in, the app sends your session token and the check passes. A visitor has no session, so the same request gets a 401. Either make the page require login, or turn JWT verification off for that function and decide inside the code what a logged-out caller is allowed to do.

Do I need to redeploy the function after adding a secret?

No. Supabase's secrets guide says functions read a new secret immediately, with no redeploy. If the function still can't see it, the name is different from what the code asks for, the secret was saved on a different project, or the function is reading it at the top of the file and the worker that failed is still the old one. Call it again a minute later before changing anything else.

Why did every edge function stop at once?

Usually the project is paused. Lovable's edge functions page says functions are unavailable while a project is paused, and a free Supabase project pauses after a period of inactivity too. The app's screens still load because they're static files, so the first sign is every backend call failing together. Restore or unpause the project, then check whether the first calls are slow while the workers start again.

Why is data null when the edge function returns an error?

Because the Supabase client only fills data for a 2xx response. For anything else it sets error to a FunctionsHttpError with the fixed non-2xx message and leaves data empty, even when your function sent a useful JSON body. The body is still there, on error.context. Read it with await error.context.json(), or .text() when the function doesn't return JSON, and the message your code wrote comes back.

My AI feature times out on long answers. Is that the edge function?

Probably. Supabase gives a caller 150 seconds before it returns a 504, and only 2 seconds of CPU time per request, though waiting on an AI provider doesn't count as CPU. A long model response that's collected in full before replying can pass 150 seconds. Stream the answer back to the browser as it arrives, or hand the job to a background task and have the app poll for the result.