Rescue · 10 min read
Your Lovable or Bolt app works in preview but breaks in production: the causes and the fixes
Most AI-built apps that work in preview and fail in production break for the same handful of reasons: environment variables baked in at build time, auth redirect URLs still pointing at the preview, missing database authorization, webhooks that are not idempotent, CORS, and no error monitoring. Each one has a specific, testable fix.
By BacklooPublished
Key takeaways
- Preview environments hide problems: one user, test keys, same-origin requests, forgiving auth settings and no real traffic.
- Client-side env vars (`VITE_*`, `NEXT_PUBLIC_*`) are baked into the bundle at build time — changing them on the host means rebuilding, and anything in them is public.
- Authorization belongs in the database or on the server. Hiding a button is not access control; Row Level Security or server checks are.
- Payment webhooks must verify signatures and be idempotent, because providers retry and deliver events more than once and out of order.
- Before launch you need error monitoring, a tested backup restore, rate limits on expensive endpoints and migrations in version control.
Why does an AI-built app work in preview but break in production?
Because the preview is a friendlier environment than production. In preview you are usually the only user, you run on test keys, requests come from the same origin, auth redirects point at the preview URL and nothing has been retried, raced or abused yet. Production changes all of those at once, and the code was never written against them.
AI coding tools optimise for the thing you can see: the screen works when you click through it. They are much weaker at the invisible parts — what happens on the second webhook delivery, what an unauthenticated request to your API returns, what the bundle contains after a build. That is not a reason to stop using them. It is a reason to check those parts deliberately before real users and real money arrive.
45%
Below are the failure modes we check first when someone hands us a Lovable, Bolt, v0 or Cursor project that "worked yesterday". They are ordered roughly by how often they cause an outage or a data leak, not by how interesting they are.
Environment variables and secrets: build time vs runtime
The most common production bug is an environment variable that is missing, wrong, or read at the wrong time. Frontend frameworks inline client-side variables into the JavaScript bundle when the app is built, not when it runs. If you set or change VITE_SUPABASE_URL or NEXT_PUBLIC_API_URL on your host after the build, the deployed code still contains the old value until you redeploy.
- Prefixed variables are public. Anything in
VITE_*,NEXT_PUBLIC_*orEXPO_PUBLIC_*ends up in the browser bundle where anyone can read it. That is fine for a Supabase anon key or a Stripe publishable key. It is a breach for a Supabaseservice_rolekey, a Stripe secret key or an OpenAI key. - Unprefixed variables only exist on the server. If client code reads
process.env.STRIPE_SECRET_KEY, it getsundefinedin production — and the AI "fix" is often to add a public prefix, which leaks the secret. - Preview and production need separate values. Test keys in production mean payments silently do nothing; live keys in preview mean your tests charge real cards.
- Secrets committed to git stay in git history. Deleting the line is not enough. Rotate the key, then remove it.
A quick check: build the app locally, then search the output for anything that looks like a secret.
npm run build
# Vite outputs to dist/, Next.js to .next/static
grep -rEo "(sk_live|sk_test|service_role|sb_secret)[A-Za-z0-9_.-]{0,12}" dist .next/static 2>/dev/null
grep -rEo "eyJ[A-Za-z0-9_-]{20,}" dist .next/static 2>/dev/null | head
# The second line finds JWTs. Decode any hit and check its "role" claim:
# "anon" is expected in a browser bundle; "service_role" is a leak.
# Library code can contain these words too, so inspect each hit rather than panic.Auth edge cases that only appear on a real domain
Auth usually breaks in production because the auth provider still thinks your app lives at localhost or the preview URL. Email confirmation links, password reset links and OAuth callbacks are all built from a configured site URL and an allow-list of redirect URLs. If your production domain is not on that list, users get sent to the preview, to localhost, or to an error page.
- Site URL in your auth provider (e.g. Supabase Auth → URL Configuration) set to the production domain.
- Redirect allow-list contains the production domain and any
www/ apex variant you actually serve — and no wildcard broader than you need. - OAuth apps (Google, GitHub, Apple) have the production callback URL registered, and their consent screens are published, not in testing mode.
- Email templates use the production domain and a real sending domain with SPF and DKIM set up, or reset emails land in spam.
- Session refresh tested: leave a tab open past the access token lifetime and confirm the next action still works.
- Sign-out tested on a second device, and protected pages re-check the session on the server, not only in a React effect.
The last item matters more than it looks. Many generated apps "protect" a route by redirecting in the browser when there is no user. The page's data request still goes out, and anyone calling the API directly gets the data. Which brings us to the bigger problem.
Authorization: who can read and write which rows
Authorization fails silently, which is why it is the most dangerous item on this list. With Supabase or Firebase, the browser talks to the database directly using a public key. The only thing stopping user A from reading user B's data is the database's own rules — Row Level Security in Supabase, Security Rules in Firebase. If those are missing or written as "allow all", every row is readable by anyone who opens the network tab.
This is not theoretical. Missing RLS exposed data in 170+ Lovable-built apps and was assigned CVE-2025-48757 (analysis). We wrote a separate, SQL-level walkthrough: Supabase Row Level Security checklist for AI-built apps. The short version:
- Enable RLS on every table in an exposed schema, then write one policy per operation (select, insert, update, delete).
- Never ship the
service_role/ secret key to the client; it bypasses RLS entirely. - Store roles in server-controlled claims (
app_metadata), not in user-editable metadata. - Test as an anonymous user and as a second user, not only as yourself.
Payments: webhooks must be verified and idempotent
Payments break in production because the happy path in preview — click pay, get redirected to a success page, mark the order paid — is not how payment providers work. The success redirect can be skipped, closed or forged. The source of truth is the webhook, and webhooks are delivered at least once, can arrive out of order, and are retried when your endpoint is slow or returns an error.
That gives you three rules. Verify the signature using the raw request body. Make processing idempotent so a duplicate delivery changes nothing. Return a 2xx quickly and push slow work to a queue. Here is a minimal Next.js route handler for Stripe that does all three:
// app/api/stripe/webhook/route.ts (Node.js runtime)
import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// server-only client: the secret key never leaves the server
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json(): needed for the signature
const signature = req.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("invalid signature", { status: 400 });
}
// stripe_events.id is a primary key, so a second delivery fails to insert
const { error } = await db.from("stripe_events").insert({ id: event.id, type: event.type });
if (error?.code === "23505") return new Response("already processed", { status: 200 });
if (error) return new Response("storage error", { status: 500 });
try {
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await db.from("orders").update({ status: "paid" }).eq("checkout_session_id", session.id);
}
} catch (err) {
// release the claim so Stripe's retry can process the event again
await db.from("stripe_events").delete().eq("id", event.id);
throw err;
}
return new Response("ok", { status: 200 });
}Two more things trip people up. The webhook signing secret is different per endpoint and per mode, so the production endpoint needs its own STRIPE_WEBHOOK_SECRET. And because events can arrive out of order, update state based on the object you receive (or re-fetch it from the API), not on the assumption that created always comes before updated.
Database migrations and backups
If your schema only exists as clicks in a dashboard and AI-generated SQL pasted into an editor, you cannot reliably recreate it, review it or roll it back. Put the schema in version-controlled migration files and apply them the same way to every environment.
- Pull the current production schema into migrations once (for Supabase:
supabase db pull), commit it, and make every later change a new migration file. - Apply migrations to a staging project before production. Never let an AI tool run DDL directly against the production database.
- Prefer additive changes: add a column, backfill, switch the code, then drop the old column in a later release.
- Know your backup policy: how often, how long it is kept, and whether point-in-time recovery is enabled on your plan.
- Do one restore into a scratch project. A backup you have never restored is a hope, not a backup.
Error monitoring: find out before your users tell you
Without error monitoring you learn about production bugs from customers, usually days late and without a stack trace. Add an error tracker (Sentry or similar) to both the frontend and any server or edge functions, upload source maps so stack traces point at real lines, and send alerts somewhere a human reads.
Add structured logs around the risky paths — sign-up, payment webhooks, file uploads, AI calls — with a request ID you can search for. And put a simple uptime check on the homepage and on one authenticated API route, so you notice when the database is down even if the static page is not.
Rate limits and CORS
Rate limits
Any endpoint that costs you money or touches auth needs a rate limit before launch. AI endpoints are the obvious one: an unprotected route that proxies to a model API can run up a large bill overnight. Limit per user and per IP, cap request size, and set a hard monthly spend limit in the model provider's dashboard as a backstop. Do the same for sign-up, login, password reset and anything that sends email or SMS.
CORS
CORS errors often appear only in production because preview served the frontend and API from the same origin. The fix is to answer the browser's OPTIONS preflight and return an explicit allow-list of origins — not to set Access-Control-Allow-Origin: * everywhere. Remember that CORS is a browser rule, not security: it does not stop curl or a script from calling your API, so authorization still has to happen on the server.
// Supabase Edge Function (Deno): explicit origin allow-list + preflight
const ALLOWED = new Set(["https://app.example.com", "https://www.example.com"]);
Deno.serve(async (req) => {
const origin = req.headers.get("origin") ?? "";
const cors: Record<string, string> = {
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
Vary: "Origin",
};
// unknown origins get no Allow-Origin header, so the browser blocks the response
if (ALLOWED.has(origin)) cors["Access-Control-Allow-Origin"] = origin;
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
// ...verify the user's JWT and do the work...
return new Response(JSON.stringify({ ok: true }), {
headers: { ...cors, "Content-Type": "application/json" },
});
});Pre-launch checklist for a vibe-coded app
Run through this list before you send production traffic to an AI-built app. Every item is something you can verify in minutes to an hour; none of them require a rewrite.
- Production build contains no secret keys (search the bundle; decode any JWTs).
- Separate env values for preview and production; client vars rebuilt after changes.
- All committed secrets rotated, not just deleted.
- Auth site URL, redirect allow-list and OAuth callbacks point at the production domain.
- Password reset, email confirmation and sign-out tested end to end on production.
- RLS enabled on every exposed table, with per-operation policies; tested as anon and as a second user.
- Protected data is checked on the server or in the database, not only hidden in the UI.
- Payment webhook verifies signatures, is idempotent, and uses the production signing secret.
- Schema in migration files; changes applied to staging first.
- Backups enabled and one restore actually performed.
- Error tracking on frontend and backend, with source maps and alerts.
- Rate limits on auth, email/SMS and AI endpoints; spend cap set with the model provider.
- CORS allow-list instead of
*; preflight handled. - Uptime check on a page and on an authenticated API route.
If you want a fast first read on where your app stands, the 60-second readiness check scores the same areas and tells you which one to look at first.
How Backloo can help
If you work through the checklist yourself, keep this page open and go top to bottom — the order is deliberate. If you would rather have an engineer do it, our vibe code rescue starts with a fixed-price audit of exactly these areas, followed by fixed-scope sprints to harden what is fragile. Once you are live, maintenance and SLA covers monitoring, updates and response times. Either way, you keep the code, the repo and the cloud accounts. Not sure whether fixing is even the right move? Read rescue, refactor or rewrite.
Questions
Why does my Lovable app work in preview but not after I deploy it?
Usually because of environment differences: client env vars baked in at build time, auth redirect URLs still pointing at the preview, CORS on a new origin, or test keys used in production. Check those four before debugging application code.
Is it safe that my Supabase anon key is visible in the browser?
Yes, the anon (publishable) key is designed to be public — but only if Row Level Security is enabled with correct policies on every exposed table. Without RLS, the anon key gives read and often write access to your data. The service_role (secret) key must never reach the browser.
Do I need to rewrite my AI-generated app to make it production-ready?
Rarely. Most production problems in AI-built apps are configuration, authorization and missing safety nets, which can be fixed in place. A rewrite only makes sense when the data model or architecture fights every change; see our guide on rescue, refactor or rewrite.
What is webhook idempotency and why does it matter?
Idempotency means processing the same event twice has the same effect as processing it once. Payment providers deliver webhooks at least once and retry on errors, so without it you can grant access twice, send duplicate emails or double-count revenue.