Why does my app work in preview but break in production?
The answer
Six things differ between the two environments, and none of them announce themselves: the environment variables, the allowlists that still name your preview URL, an unverified email sending domain, a database that is not the same database, caching that only exists in production, and a production build that is stricter than the dev server.
By Muhammad Bilal12 min read
The short version
- Preview and production are not the same app running in two places. They are two different apps that happen to share a codebase — different variables, different domain, often a different database, and a different build.
- Almost every one of these six failures is silent. Nothing throws, nothing logs, the button just does nothing — which is why people spend days on it and conclude the app is broken when the configuration is.
- Running `npm run build` on your own machine, once, catches more production-only bugs than any other single thing you can do. The dev server is forgiving in ways the real build is not.
- Anything that stores a URL — auth redirects, CORS origins, OAuth clients, webhook endpoints — is a copy of your preview address that somebody has to go and change. There is no automatic step.
- On most hosts, changing an environment variable does nothing to the site that is already live. You have to redeploy for it to take effect, and that catches people every single time.
It works in the editor. Every button, every form, the login, the emails. You point your domain at it, open the live site, and something is wrong — a form that submits into nothing, a login that loops back to itself, emails that never arrive, a page that was fine ten seconds ago in the preview tab next door.
This is the single most common thing people write to me about, and it is almost never a broken app. It is a configuration difference between two environments that were never actually identical, and there are six of them. In four years of doing this I can count on one hand the times it was something else.
Here is each one, what it looks like from the outside, and how to confirm it without guessing.
Why the two are not the same app
The mental model that causes the confusion is that preview is your app running in a window and production is the same app running on your domain. It is not. They are two builds, from the same source, with different inputs — different environment variables, a different hostname, usually a different database, a different build mode, and a different set of third-party services willing to talk to them.
Every one of the six causes below is a place where those inputs diverge. And every one of them fails quietly, because a configuration mismatch does not throw an exception. The code runs perfectly. It just runs with the wrong values, or against a service that has decided not to answer.
That silence is the actual problem. A crash would tell you where to look.
1. The environment variables are not the same variables
Your app reads its keys, URLs and secrets from environment variables. Preview has one set. Production has another set, and it is usually not complete — a variable that was added later during development never made it across, or it exists but holds the test-mode value.
Three specific traps live here.
Variables are baked in at build time, not read at runtime. On Vercel, Netlify and most static or hybrid hosts, the values are read while the site is being built and compiled into the output. Changing a variable in the dashboard does nothing at all to the deployment that is already live. You have to trigger a new deployment for it to take effect. People fix the actual problem, refresh, see the same failure, and conclude the fix was wrong.
The public prefix is not decoration. VITE_, NEXT_PUBLIC_ and PUBLIC_ are instructions to the build tool to embed that value in the JavaScript sent to every visitor. Without the prefix, the variable exists only on the server, and any browser-side code reading it gets undefined. This produces two opposite failures: something legitimate is undefined in the browser because the prefix is missing, or something private is fully readable because the prefix is there. Both are common and the second one is worse.
Test keys and live keys are different keys. Stripe, and most payment and messaging providers, issue two entirely separate sets of credentials. A test key on your live site accepts test card numbers and rejects real ones. A live key in preview charges real cards during development, which is a worse day.
How you know you have it: the failing request returns 401, 403, or a message about an invalid or missing key. Or something reads as undefined in the browser console on the live site and has a value in preview.
The fix: open the production environment variables and the preview environment variables side by side and compare them line by line, including the values, not just the names. Then redeploy. Always redeploy — even when you are certain it should not be necessary.
2. Something still has your preview URL in an allowlist
This is the one that catches almost everyone, because the offending setting is not in your app at all. It is in a dashboard somewhere, holding a copy of an address you stopped using.
Every service that redirects a user back to you, or refuses requests from unknown origins, keeps a list of URLs it will accept. While you were building, those lists got your preview URL, because that was the app. Nothing updates them when you go live.
The usual suspects:
- Auth redirect URLs. In Supabase this is the Site URL plus the Redirect URLs allowlist. If the live domain is not on it, the user signs in successfully and gets sent back to the preview host — or to nowhere. Login "not working" in production is this, most of the time.
- OAuth authorised redirect URIs. Google, GitHub and the rest match the callback URL exactly, character for character. A missing
www,httpinstead ofhttps, or a trailing slash is a mismatch. The error appears on their page, not yours, which is why people miss it. - CORS allowed origins. Your API or storage bucket permits requests from the preview origin only. The browser blocks the call before it leaves. In the Network tab it looks like the request failed for no reason; the actual explanation is sitting in the console.
- Webhook endpoints. Stripe, Resend, Clerk and similar services send events to a URL you registered. That URL is still the preview one. Payments then succeed on the payment provider's side and your database never hears about it — the customer is charged and gets nothing, which is the most expensive version of this failure.
How you know you have it: authentication redirects to the wrong host or loops, or the browser console shows a CORS message, or money moves and your app does not notice.
The fix: list every third-party service the app touches and open each one's dashboard. Anywhere it stores a URL, add the production domain. Keep the preview URL on the list too — you still need it to work while building. And on Vercel, be aware that each preview deployment gets its own generated address, so allowlisting one specific preview URL is a temporary fix by nature.
3. The email sending domain was never verified
Email deserves its own place on this list because it fails more confidently than anything else here. There is no error on your side. The code runs, the provider returns success, and the message does not arrive.
The reason is that transactional email providers let you send freely to your own address before you have proved you own a domain. That is exactly what you do while building — you sign up as yourself, trigger the welcome email, and it lands. The moment a real user with a different address signs up, the provider refuses, because sending to arbitrary strangers from an unverified domain is how spam works and every provider blocks it by default.
So the feature is not broken. It was never actually tested.
How you know you have it: email works when you test it and does not work for anyone else. Check the provider's own logs — Resend, Postmark, SendGrid all show delivery attempts and rejection reasons, and the answer is usually sitting there in plain language.
The fix: verify the sending domain properly, which means adding DNS records to your domain and waiting for them to propagate. Add SPF and DKIM while you are there, or your mail arrives in spam, which is a different failure that looks identical from the user's side. Then send a real test to an address you do not own — a friend's, a second personal account, anything that is not the one you signed up with.
4. The database is not the same database
Sometimes preview and production point at the same database, and sometimes they very much do not. Both situations cause problems and they are opposite problems, so it is worth knowing which you have before you change anything.
If they are separate, then the schema changes you made while building may not have been applied to the production database. A column exists in one and not the other. Queries fail, or silently return nothing. The seed data you have been developing against — the categories, the plan tiers, the default rows the app assumes exist — is not there either, so screens render empty and look broken.
Row-level security is the sharpest version of this. If it is switched on in production and off in preview, every query returns zero rows on the live site and the app looks empty rather than broken. If it is on in preview and off in production, your live database is open to the internet, which is a much more serious matter and worth reading about separately in the five-minute security check.
If they are shared, you have the reverse problem: you are developing directly against live customer data, and a migration run during a build touches real records.
How you know you have it: queries return empty arrays rather than errors. A page that showed data yesterday shows a blank state with no message. Or the app works completely except for one feature that depends on a table you added late.
The fix: confirm which database each environment points at, apply every pending migration to production explicitly rather than assuming a deploy did it, and check that row-level security is enabled and identically configured in both. If you are sharing one database between environments, separating them is worth doing before you have customers rather than after.
5. Production caches and preview did not
Preview environments disable caching so that your changes appear the instant you make them. Production caches aggressively, because that is most of what makes it fast. Neither behaviour is wrong and the transition between them produces some genuinely disorienting bugs.
The most disorienting is stale content: you deploy a fix, you can see it in the deployment logs, and the live site keeps serving the old version — to you, or to some visitors and not others, depending on which edge location they hit.
The subtler and more damaging one is user-specific content served from a shared cache. If a page that shows the logged-in user's name or data gets cached at the CDN, the next visitor can be served the previous visitor's page. This is rare, it is severe, and AI-generated code does not reason about it at all because there is nothing in the code to look wrong.
How you know you have it: a hard refresh shows the correct page and a normal refresh does not. Or an incognito window and a normal window disagree about what the site says. Or — the one to take seriously — a user reports seeing something that belongs to someone else.
The fix: any page or endpoint that returns data specific to one user must be explicitly marked as uncacheable. Everything else can and should be cached. If you are seeing stale deploys rather than crossed-over content, the cause is usually a cache header on an asset that changes without its filename changing, and the fix is content-hashed filenames so a new build is a new URL.
6. The production build is stricter than the dev server
The dev server is not a smaller version of the production build. It is a different program with different rules, deliberately relaxed so you can move fast. Things it tolerates, the real build refuses.
Hydration mismatches. Server-rendered HTML must match what the browser produces on first render. Call new Date(), Math.random(), or read window or localStorage during render, and the two will not match. In development you get a warning in the console. In production the framework throws away the server HTML and re-renders, or the page breaks outright — and a fair amount of AI-generated code does exactly this, because it reads perfectly sensibly.
Missing dependencies. Production installs only the packages listed as real dependencies, not devDependencies. If something imported by your application code ended up in the wrong section of package.json, the build fails on the host and succeeded on your machine, where everything is installed anyway.
Case-sensitive file paths. macOS and Windows treat Button.jsx and button.jsx as the same file. The Linux machine your build runs on does not. Import a component with the wrong capitalisation and it works everywhere except the one place that matters.
Stricter compilation. Type errors and lint failures that the dev server warns about will stop a production build entirely, and the failure is in a build log you may not think to open.
How you know you have it: the deployment fails and you never see the site, or the site loads and the console shows a hydration error, or one route works and another does not with no obvious pattern.
The fix, and it is the single highest-value thing on this page: run npm run build on your own machine before you deploy. Not npm run dev — the actual production build. It takes a minute and it surfaces every one of these failures locally, with a real error message, in an environment where you can fix it immediately instead of guessing at a deployment log.
The ten-minute sweep before you launch
Before you point a domain at anything, walk this list once. It is boring and it removes almost this entire category of problem.
Run the production build locally and confirm it completes. Compare the production and preview environment variables value by value, and redeploy afterwards. Open every third-party dashboard and add the production domain anywhere a URL is stored — auth redirects, OAuth clients, CORS origins, webhook endpoints. Verify the email sending domain and send a test to an address you do not own. Confirm which database production uses and that every migration has been applied to it. Then, on the live domain in an incognito window, do the full loop yourself: sign up as a new user, receive the email, log in, do the main thing your app does, and pay if there is a payment.
That last step is the one people skip because they have done it a hundred times in preview. Do it once on the real domain, as a stranger. It finds things nothing else finds.
Most of this is what the launch and deployment work actually consists of — the code is usually fine, and the day goes into the twenty places that hold a copy of a URL.
If it is broken right now and you need it working
If your app is live and something on this list is currently costing you signups or payments, the fastest thing you can do is send me the symptom — what the user does, what happens instead, and a screenshot of the browser console if you can get one. I will tell you which of the six it is. No charge, and usually within a few hours, because these are recognisable on sight once you have seen each of them enough times.
If you want it dealt with properly rather than diagnosed, I do a Production-Ready Audit that covers this list and the rest of what breaks after launch — every item checked against your actual app, with a written fix list in priority order and honest costs beside each one. From $499, back in five to seven days.
The wider version of this list — the ten things that go wrong in AI-built apps generally, not just at the preview-to-production boundary — is in 10 problems every Lovable- or Cursor-built app has in production. If you are further back than that and just want to know whether your data is exposed, start with the five-minute check. And there is more on getting a stalled build finished on the MVP development page, or answers to the questions I get most often on the FAQ.
Follow-up questions
What people ask next
How do I tell which of the six it is?
Open your browser's developer console on the live site and look at two tabs. The Console tab tells you about build and hydration problems — cause six. The Network tab tells you about everything else: a request that returns 401 or 403 points at variables or allowlists, a request that never fires at all points at a build problem, and a request that succeeds while nothing visible happens usually points at the database. If both tabs are clean and something still does not work, it is almost always email or a webhook — the two things that fail entirely on someone else's server.
Why does the preview let me get away with things production does not?
Because preview is optimised for the loop you are in while building — fast, forgiving, and permissive by default. It relaxes rules that only matter once real people and real money are involved: it will happily send mail from an unverified domain to your own inbox, run against a database that has no rules on it, and skip the strict production build entirely. None of that is a flaw in the tool. It is what makes the first eighty per cent fast.
I changed the environment variable and it still does not work. What now?
Redeploy. On Vercel, Netlify and most similar hosts, environment variables are read at build time and baked into the deployment — the site that is already live keeps the old values until a new build replaces it. This is the single most common false dead end in the whole list, because you have genuinely fixed the problem and the evidence in front of you says you have not.
Is this a security problem or just a broken-features problem?
Mostly broken features — but cause one overlaps with a real security issue. Any variable with a public prefix such as VITE_, NEXT_PUBLIC_ or PUBLIC_ is compiled into the JavaScript your visitors download, so if a service key or a private API secret ended up behind one of those prefixes it is readable by anyone with your site open. That is worth checking on its own terms, separately from whatever is currently broken.
Can I avoid all of this by not using previews at all?
No, and you should not want to. The gap is not caused by having a preview environment — it is caused by the two environments differing in ways nobody wrote down. The fix is a launch checklist you run once per environment-dependent value, not the removal of the thing that lets you build quickly.
Related reading
Launch & Deployment
A finished build taken online properly — hosting, CI/CD, secrets, domain, SSL and monitoring, all inside your own accounts.
From $299 · 2–5 days

Muhammad Bilal
Full Stack AI Developer · Faisalabad, Pakistan
I build and rescue production AI SaaS products with Next.js, Supabase, Stripe and Claude. Most of my work is finishing apps that were started with Lovable, Bolt, Cursor or Replit and stalled somewhere between working and shippable.
5.0★ · 100% job success · 35+ projects delivered
