Engineering

Works in preview, breaks after deploy

A differential diagnosis

This failure is common across AI app builders, and it is almost never random. A preview and a production deployment are two different runtimes with two different sets of bindings, two different databases, and two different routing layers. Here are six classes that cover the cases we see most often, with the symptom, the confirmation step, and the fix for each.

Author
By Fabricate Team
Last updated
Updated July 26, 2026
Reading time
20 min read
Key takeaways
  • A preview and a production deploy are different runtimes. The preview database is a local SQLite file inside a container; production is a remote D1 database with its own migration history.
  • The fastest triage question is not "what broke" but "does it break for everyone on every request, or only on one route". That single answer rules out most of the six classes before you open a file.
  • Unresolved template placeholders in wrangler config are the cleanest failure to diagnose and the easiest to miss: the binding resolves to undefined and the Worker returns a 500 on first use.
  • A 404 on the root URL with working /api routes is almost always missing static-asset delegation, not a broken build.
  • Migrations that ran in preview did not necessarily run in production. They are two separate commands against two separate databases.
  • A preview that reports success is not a verified production deploy. Any builder that tells you otherwise is overstating what it checked.
Table of Contents

The short answer

Your app works in preview and breaks in production because the preview is not production. It is a development server running inside a container, with a local database file, a dev-mode asset pipeline, and bindings that were resolved for that container. Production is a deployed Cloudflare Worker with a remote D1 database, a static asset binding, and whatever ids your wrangler config actually contains. Code that is correct in one can be wrong in the other, and nothing about the generation step forces the two to agree.

Most instances fall into one of six classes: unresolved resource placeholders in wrangler config, bindings or environment values that only existed in the dev sandbox, routing gaps that only appear on a real origin, a Worker entry that does not export fetch in the shape the runtime requires, database migrations that ran against the local database but never against the remote one, and cold-start behaviour that a warm dev server hides.

Each class produces a distinctive symptom. Read the symptom first, then jump to the section that matches. Guessing is slower than triaging.

Note

This applies to every AI app builder that gives you a live preview, not just Fabricate. The specific file paths below are from the Fabricate codebase because that is what we can cite honestly, but the failure classes are universal.

Triage before you change anything

Answer three questions in order. Each one rules out a group of classes, and you can answer all three from a browser and a terminal.

Question one: does the root URL load at all? Open the production URL. If you get a 404 or a blank page with a 404 in the network tab, but hitting an /api path returns JSON, the Worker is alive and the problem is static-asset routing, not your code. Skip to class 3. If the root loads and only certain actions fail, continue.

Question two: does the failure happen on every request or only on data operations? Open the network tab and reproduce. A 500 on the first request that touches the database, with the page shell rendering fine, points at classes 1, 2, or 5. Every route failing at once, including ones that worked in preview, points at class 4.

Question three: does it fail consistently, or did it fail once and then start working? Intermittent first-request failures that resolve on reload are class 6. Consistent failures are not.

Write down which class you landed on before you start editing anything. The most expensive way to debug this category is to change three things at once and lose track of which one mattered.

Tip

Check the response body, not just the status code. A 500 with a JSON error object tells you your handler ran and threw. A 500 with an HTML error page usually means the request never reached your handler at all. Those are different bugs.

What actually differs between preview and production

Preview and production differ at six layers, and knowing which layer you are looking at collapses most of the guesswork. The table below maps each layer to what it is in each environment and to the symptom you see when the two diverge.

The row that surprises people most is the database. In Fabricate, preview migrations are applied with a local flag: the command built by worker/agents/services/implementations/d1MigrationPlanning.ts is `npx wrangler d1 migrations apply <database> --local --config <wrangler config>`. That writes to a SQLite file inside the container. The deployed Worker binds to a remote D1 database by its `database_id`. They are not the same store, they do not share migration history, and a table that exists in one can be absent in the other.

LayerPreview sandboxDeployed WorkerSymptom when they diverge
DatabaseLocal SQLite file, migrated with --localRemote D1 bound by database_id500 on first query, "no such table" or "no such column"
KV / R2 bindingsResolved for the running containerResolved from ids in wrangler configReading a property of undefined, 500 on first use
Static assetsA plain Vite dev server serves index.html for you; a worker-backed preview does notWorker must delegate to env.ASSETS.fetchRoot URL 404s while /api still returns JSON
API routingCloudflare Vite plugin runs the worker alongside the dev serverThe Worker's own route table, nothing elseAPI calls return HTML or 404 instead of JSON
Environment valuesWhatever the container shell hadOnly declared vars and bindingsThird-party calls fail with 401 or a null key
StartupAlready warm by the time you look at itFirst request may hit a cold isolateOne slow or failed first request, fine on reload

Layer-by-layer differences between a preview sandbox and a deployed Cloudflare Worker

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Class 1: unresolved resource placeholders

The symptom is a 500 the first time your app touches a KV namespace, a D1 database, or an R2 bucket, while every purely static page works. The cause is that your wrangler config still contains a template token like `{{KV_SESSIONS_ID}}` or `{{D1_APP_ID}}` where a real resource id should be. The gate's own comment on this rule states the split plainly: production deploys reject that config, while sandbox dev tolerates it and the binding never resolves.

To confirm it, open wrangler.jsonc and search for `{{`. That is the whole test. If you find a double-brace token anywhere in a `kv_namespaces`, `d1_databases`, or `r2_buckets` entry, you have found the bug.

The fix is to provision the real resource and paste the real id in place of the token. If you are on Fabricate, asking the agent to provision the missing resource and update wrangler config is usually one message; the point of knowing the cause is that you can ask for the right thing instead of describing a mystery 500.

This class is common enough that Fabricate ships a dedicated gate rule for it. In worker/agents/validators/preDeploySafetyGate.ts, the `unresolved_resource_placeholder` rule scans wrangler.jsonc, wrangler.json, and wrangler.toml for the `{{TOKEN}}` shape and raises an error-severity issue whose message says exactly this: provision the resource and replace the placeholder, otherwise the binding returns undefined and the Worker 500s on first use. A sibling rule, `unresolved_d1_binding`, fires when worker code references `env.DB` but the config has a placeholder or otherwise invalid `database_id`.

Important

A sandbox dev server will often tolerate an unresolved placeholder and keep serving pages, which is exactly why this reaches production. Tolerant dev environments are the reason this whole article exists.

Class 2: bindings and environment values that only exist in dev

The symptom is a feature that worked end to end in preview and returns 401, 403, or an empty result in production, usually anything that talks to a third-party API. The cause is that the value your code reads out of the environment existed in the dev container and does not exist on the deployed Worker.

Confirm it by finding every place your Worker reads from `env` and checking each one against your wrangler config. Anything that is not a declared binding, a declared var, or a secret you explicitly set on the deployed Worker will be undefined at runtime. The deployer in worker/services/deployer/deployer.ts uploads your built modules and the wrangler metadata that describes your bindings. It does not sweep up ad-hoc values that only ever lived in a container shell.

The fix has two halves. For anything non-secret, such as a public key or a base URL, declare it as a var in wrangler config so it travels with the deploy. For anything secret, set it as a secret on the deployed Worker rather than baking it into the bundle, and add a startup guard so a missing value fails loudly instead of silently producing a broken request.

The related failure worth naming here is the opaque 500. Fabricate has a gate rule, `handler_db_call_unguarded`, that flags a route handler awaiting a database query with no try/catch. Its message spells out why that matters: Hono's default error handler hides the cause, so an unhandled database error reaches the user as a bare 500 with no information. Wrapping the call and returning a structured JSON error turns an unexplainable production failure into a one-line diagnosis.

Class 3: routing that only manifests on a real origin

The symptom is unmistakable once you have seen it: your API works, and your app does not. Requests to /api/anything return correct JSON. The root URL returns 404. Deep links to client-side routes return 404. Nothing in the build log looks wrong.

The cause is that a full-stack Cloudflare Worker is responsible for serving your static assets, and yours is not doing it. When wrangler config declares an `ASSETS` binding, the Worker must delegate every non-API request to `env.ASSETS.fetch(...)`. A plain Vite dev server hands you index.html for free, which is why this omission is invisible in a frontend-only dev setup. Worth being precise here: in a worker-backed preview like Fabricate's, which runs the Cloudflare Vite plugin, the same omission breaks the preview too rather than waiting for production. That is exactly why it is gated before deploy instead of being left to production to discover.

Confirm it by requesting the root URL with curl and reading the status. A 404 from the root while /api returns 200 is conclusive. Then open worker/index.ts and search for `ASSETS`. If the string does not appear, you have found it.

The fix is a catch-all that branches on the path: return a JSON 404 for /api/* and delegate everything else to the assets binding. Fabricate enforces this with the `missing_spa_fallback_delegation` rule, which fires when wrangler declares an ASSETS binding but worker/index.ts never references `env.ASSETS`. The rule's own comment documents the user-visible consequence precisely: the root path and every client-side route return 404, and the preview iframe retries ten times and then displays "Preview Not Available", even though the Worker is healthy for /api/* calls.

The mirror-image bug is a missing API catch-all. Without one, a request to an /api path that matches no route falls through to your SPA fallback and returns HTML. Your frontend then tries to parse HTML as JSON and throws a confusing syntax error somewhere far from the actual cause. Fabricate raises `missing_api_catchall` for this, at warning severity, with the message that unmatched routes will return HTML instead of JSON.

Tip

If your frontend is throwing "Unexpected token < in JSON at position 0" in production only, stop debugging the frontend. You are receiving an HTML page from a route you expected to be JSON. Fix the API catch-all.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Class 4: a Worker entry that does not export fetch correctly

The symptom here is the most total: nothing works. Every route, including /api, returns an error or a 404, and the deploy itself reported success. The cause is that the Cloudflare runtime could not find, or could not correctly invoke, the fetch handler your Worker exports.

There are two common shapes. The first is a Worker entry that wraps `app.fetch` in a custom fetch method and registers routes lazily at request time. Fabricate's `invalid_worker_entry_fetch_export` rule is explicit about both the required shape and the reason: worker/index.ts must keep the plain Workers export `export default { fetch: app.fetch }`, and must not wrap app.fetch in a custom fetch method or lazily register routes at request time, because lazy registration breaks Hono route matching at runtime.

The second shape is routes that exist in a file nobody mounts. A generated app may define perfectly good handlers in worker/userRoutes.ts while worker/index.ts never calls `userRoutes(app)` at module scope. The routes are compiled, they are type-correct, and they are unreachable. Fabricate raises `worker_routes_not_mounted` for exactly this, and a companion rule, `top_level_worker_route_registration`, for the subtler variant where route registrations appear after the `userRoutes` function instead of inside it.

Confirm class 4 by reading worker/index.ts top to bottom. You are looking for three things: a module-scope mount of your route module, a plain default export with a fetch property, and no route registration inside a request handler. The fix is to restore that shape. It is usually a very small edit, which is frustrating given that the symptom is a completely dead application.

One more variant worth knowing, because it produces a crash rather than a 404: a generated worker file that still contains a stub declaration such as `const app = { _stub: true }`. This redeclares the app and crashes the Worker. Fabricate flags it as `worker_app_stub_leak`.

Class 5: migrations applied locally, never applied remotely

The symptom is a 500 on the first request that reads or writes a specific table, while the rest of the app behaves normally. Signing up fails but the marketing page renders. One feature 500s and three others are fine.

The cause is that your preview database and your production database are separate stores with separate migration histories. Preview migrations run against a container-local SQLite file. Production migrations are a distinct step executed against the remote D1 database after the deploy, in Fabricate's case by `runPostDeployD1Migrations` in worker/services/sandbox/sandboxSdkClient.ts, which acquires a per-database lock, reads the applied-statement history, and applies what is outstanding. That step can fail, or partially apply, independently of the deploy succeeding.

Confirm it by querying the production database directly for the table in question. If the table is missing, or a column your code references is missing, you have your answer. The partial case is the nastier one: template-seeded tables exist, so the app looks alive, while the tables your feature actually needs were never created.

There are three distinct root causes underneath this class, and they need different fixes.

Important

A green deploy toast does not mean your production migration succeeded. Fabricate now surfaces the production migration outcome so publish can warn on a failed or partial migration rather than reporting success over an app whose tables are missing. If your builder does not surface this, check the database yourself.

No migration SQL was ever generated

If drizzle.config.ts is missing or empty, `drizzle-kit generate` produces no migration SQL at all, so any table your schema declares beyond the template-seeded ones is never created anywhere. Fabricate's `empty_drizzle_config` rule catches this, and its comment names the reason it is so confusing: the template-seeded tables (users, sessions) keep working, so the broken case looks partially alive, which is worse.

The SQL was generated into a directory nobody applies

drizzle-kit writes generated SQL to its configured `out` directory. Wrangler applies SQL from the binding-level `migrations_dir`. If those two paths diverge, drizzle-kit dutifully writes files that wrangler never scans, and the first query against the new table surfaces as an opaque 500. Fabricate parses the drizzle config `out` value specifically to detect this divergence, in worker/agents/services/implementations/d1MigrationPlanning.ts.

The code references columns the schema never declared

Drizzle's inferred types are permissive enough that inserting or selecting a column that does not exist in your schema can still typecheck. D1 then throws "no such column" at runtime, which usually surfaces as a 500 on the first auth or registration request. Fabricate's `schema_column_drift` rule builds a map of declared columns per table and walks the AST of every other worker file looking for references that are not in it.

Class 6: cold start and container warmup

The symptom is a failure that will not reproduce. The first request after a deploy times out or errors, you reload, and everything works. You cannot make it happen again, so you assume you imagined it. You did not.

A dev server is already warm by the time you look at the preview. A freshly deployed Worker may serve its first request from a cold isolate, and a sandbox container has an explicit window between being spawned and its dev server binding to a port. Fabricate documents that window in worker/agents/services/implementations/deployment-retry-classification.ts as a normal 20 to 40 second warmup between sandbox spawn and Vite binding to port 8001, and keeps two separate pattern lists so the system can tell a container that is still starting from a session that is genuinely dead.

That distinction matters more than it sounds. The same file records what happens when you get it wrong: treating a warmup error as a session-death signal causes a cascade where the probe fails, the classifier orders a reset, the sandbox is destroyed and recreated, the new one has the same warmup window, and the loop repeats. Retrying with backoff is the correct response to a warmup error. Recreating the environment is not.

To confirm class 6, reproduce deliberately: wait several minutes without touching the app, then send a single request and time it. If the first one is slow or fails and the second is instant, you are looking at cold start and not at a bug in your code.

The fix is mostly not to panic. Keep the first request cheap: avoid running migrations, seeding, or a multi-round-trip auth handshake on the very first hit. On the monitoring side, tolerate a single failed probe. Fabricate polls health on a fixed thirty-second interval and requires two consecutive unhealthy checks before it tears anything down, precisely because one failed probe is routinely just a container mid-warmup or a Vite dependency-optimization pause.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Why "deploy succeeded" is not "production works"

This category is a recurring source of support and refund requests, and the reason is structural rather than incidental. A build that compiles, a preview that renders, and a deploy API that returns 200 are three different claims, and none of them is the claim the user actually cares about, which is that the live URL serves a working application.

Fabricate runs a pre-deploy safety gate over generated files before deployment. It lives in worker/agents/validators/preDeploySafetyGate.ts and it enforces 28 named rules covering the failure classes above plus a set of React render-loop patterns. Every rule named in this article is one of those 28. What that buys you is that a generated app is checked for these specific structural mistakes before it ships. It is not a guarantee, and we would rather say so than imply otherwise.

Here is the specific limitation, and you can read it in the code. In the phasic pipeline, when the gate finds blocking issues, the system first applies deterministic fixes, then attempts a targeted repair pass on what remains. If issues still remain after that, worker/agents/execution/phasicStateMachine.ts logs them as warnings and deploys anyway, on the reasoning that post-deploy fixes will attempt to resolve them. The comment explaining the decision is one line: throwing there would crash the entire phase and leave the preview on the template. It is a deliberate trade, and the cost of the trade is that a small number of flagged issues reach a live deploy.

The honest framing for any AI app builder is this. Static analysis catches the classes it has rules for. It cannot catch a business-logic error, a third-party API that rejects your key, or a schema decision that is valid but wrong. A green preview means the generator did not produce something obviously broken. Verifying production means opening the production URL and exercising the real path yourself.

Note

If a builder markets deploys as fully verified, ask what specifically was verified. "The build compiled" and "the deploy API returned 200" are common answers dressed up as stronger ones.

The pre-publish checklist

Run these six checks before you announce a URL to anyone. They are quick, and together they cover every class above.

1. Search wrangler config for `{{`. Any hit is an unprovisioned resource. Fix before deploying.

2. Curl the production root URL and read the status. A 404 means asset delegation is missing.

3. Curl one API route that exists and one that does not. Both should return JSON, not HTML.

4. Open worker/index.ts and confirm the plain default export with a fetch property, and a module-scope mount of your route module.

5. Query the production database for every table your app writes to. Do not infer this from the preview.

6. Exercise the single most important user path end to end on the live URL. Sign up, create the thing, reload, confirm it persisted.

The last one catches what no static analysis can, because it is the only check that tests your actual product rather than its structure.

A repair prompt that gets a useful answer
My deployed app is failing but the preview works. Here is what I observed:

- Production URL: {{url}}
- The root URL returns {{status code}}
- {{one API route}} returns {{status and first line of body}}
- The failure happens {{on every request / only when I do X}}
- Browser console error: {{exact text}}

Before changing anything, check these in order and tell me which one it is: unresolved {{...}} placeholders in wrangler.jsonc, missing env.ASSETS delegation in worker/index.ts, routes defined in worker/userRoutes.ts but not mounted at module scope, and production D1 tables that exist in the local database but not the remote one. Then fix only that.
Vague reports get vague fixes. Naming the status codes, the exact error text, and the four candidate causes turns an open-ended debugging session into a targeted one, and it stops the agent from rewriting working code while hunting.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Frequently Asked Questions

Why does my API work in production but the homepage returns 404?
Your Worker is healthy and serving API routes, but it is not serving your static files. When wrangler config declares an ASSETS binding, the Worker itself is responsible for handing back index.html and every client-side route, by delegating non-API requests to env.ASSETS.fetch. A plain frontend dev server does that for you automatically, which is why the omission can go unnoticed until deploy. Open worker/index.ts and search for ASSETS. If it is not there, add a catch-all that returns JSON 404 for /api/* and delegates everything else to the assets binding.
Why do I get "no such table" in production when the database worked in preview?
Because they are two different databases. The preview runs migrations against a container-local SQLite file with wrangler's --local flag; the deployed Worker binds to a remote D1 database with its own separate migration history. A migration that ran in preview did not run in production unless a distinct post-deploy migration step executed successfully against the remote database. Query the production database directly for the missing table rather than inferring its state from the preview.
What does an unresolved placeholder in wrangler.jsonc actually do at runtime?
It leaves you with a binding that does not resolve. A token like {{KV_SESSIONS_ID}} where a real namespace id belongs means that at runtime env.KV_SESSIONS is undefined, and the first line of code that touches it throws, surfacing as a 500. Production deploys frequently reject the config outright, but a tolerant dev sandbox will keep serving pages, which is exactly how the problem reaches production unnoticed. Search your wrangler config for a double open brace before every deploy.
My app fails once right after deploy and then works. Is something broken?
Probably not. A freshly deployed Worker can serve its first request from a cold isolate, and a container-based preview has a real window, roughly twenty to forty seconds, between being spawned and its dev server binding to a port. A single failed first request followed by consistent success is cold start, not a code bug. Keep the first request cheap by not running migrations or seeding on it, and treat a single failed health probe as noise rather than a failure signal.
Does a pre-deploy safety gate mean my deploy is guaranteed to work?
No, and any tool that implies otherwise is overstating what it checked. Fabricate's gate enforces 28 named rules against the generated files and catches the structural classes described here, but in the phasic pipeline it deliberately logs unresolved blocking issues as warnings and proceeds with the deploy rather than crashing the generation phase and leaving you stuck on a template preview. Static analysis also cannot catch business-logic errors, a rejected third-party API key, or a schema decision that is valid but wrong for your product. Open the live URL and exercise the real path.
How do I stop my frontend throwing "Unexpected token < in JSON"?
That error means your frontend received HTML where it expected JSON, which almost always means an /api request matched no route and fell through to the SPA fallback that serves index.html. Add a catch-all for unmatched /api/* paths that returns a JSON error object with an appropriate status. The fix belongs in the Worker, not in the frontend parsing code, and it converts a confusing client-side syntax error into an honest 404 you can read.
Should I test in preview at all, or just deploy and check?
Test in preview, then verify in production. Preview is where you iterate quickly on behaviour and appearance, and it catches the majority of ordinary bugs cheaply. Production verification is a separate, shorter pass over the six things preview cannot tell you: real bindings, real resource ids, real asset routing, the real Worker entry contract, the remote database, and cold start. Treating them as one step is the mistake, not using preview.
Is this problem specific to Cloudflare Workers?
No. The specific mechanisms differ by platform, but the shape is identical everywhere: a development server is permissive and pre-warmed, and a production runtime is strict and cold. Serverless functions on other providers hit the same classes under different names, such as environment variables that were only ever set locally, rewrite rules that only exist in a dev proxy, and database URLs that point at a local instance. The triage sequence in this article transfers directly.