{"slug": "migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker", "title": "Migrating a live SaaS from Vercel + Supabase to a single Cloudflare Worker", "summary": "A developer migrated the live SaaS ReddTrends from Next.js on Vercel with Supabase to a single Cloudflare Worker, using TanStack Start and D1. The migration preserved user passwords by overriding Better Auth's verification to support legacy bcrypt hashes, and used an idempotent import process to move data from Postgres to SQLite. The weekly AI pipeline and daily emails now run via Worker Cron Triggers.", "body_md": "I run [ReddTrends](https://reddtrends.com). It reads Reddit every week and scores what founders are complaining about into niche opportunities, with GO / WATCH / AVOID verdicts. A few hundred users, a weekly AI pipeline, a daily email job.\n\nLast Tuesday I moved it off Next.js on Vercel + Supabase and onto one Cloudflare Worker. Same domain, Creem subscriptions live the whole time.\n\nFour things were worth writing down: the password hashes, the D1 import, the weekly pipeline, and one payment row I deleted by accident.\n\n| Before | After | |\n|---|---|---|\n| App | Next.js on Vercel | TanStack Start + React 19 on Cloudflare Workers |\n| DB | Supabase (Postgres) | Cloudflare D1 (SQLite) |\n| Auth | Supabase Auth (GoTrue) | Better Auth |\n| Weekly AI pipeline | GitHub Actions cron → one long HTTP endpoint | Worker Cron Trigger → Cloudflare Workflows |\n| Daily emails | GitHub Actions cron | Worker Cron Trigger |\n| Inbound mail | Zoho | Cloudflare Email Routing |\n| Outbound mail | Resend | Resend (unchanged) |\n| Files / cache | — | R2 + Workers KV |\n\nRow 3 is the one I cared about. The rest came along for the ride.\n\nI didn't port the Next.js app. OpenNext runs Next.js on Workers and is the lower-risk path if you have a large app to move. Mine was small, and I rebuilt on a TanStack Start boilerplate that already targeted Workers natively, so there was no adapter layer to keep working.\n\nLeaving Supabase Auth means inheriting 938 bcrypt hashes from GoTrue. Better Auth hashes with scrypt. I could have forced a password reset on everyone, which is a good way to lose the users who were only half committed.\n\nYou can just override the verify function:\n\n``` python\nimport bcrypt from 'bcryptjs';\nimport { verifyPassword as verifyScryptPassword } from 'better-auth/crypto';\n\n// GoTrue emits $2a$; bcryptjs emits $2b$; $2y$ is the PHP variant.\nconst BCRYPT_HASH = /^\\$2[aby]\\$/;\n\nexport async function verifyPasswordCompat({ hash, password }) {\n  if (BCRYPT_HASH.test(hash)) {\n    return bcrypt.compare(password, hash);\n  }\n  return verifyScryptPassword({ hash, password });\n}\nbetterAuth({\n  emailAndPassword: {\n    password: { verify: verifyPasswordCompat }, // verify only, never hash\n  },\n});\n```\n\nThis works on Workers because bcrypt `compare`\n\npulls the salt out of the stored hash, so it needs no RNG and runs fine on workerd. bcrypt *hashing* needs a PRNG, and that's where people usually hit the wall. We never call it. New passwords go through the default scrypt hasher, and migrated users verify against their old hash indefinitely.\n\nIt costs 100-250ms of CPU per login for those users. For something that happens once a session I decided I didn't care.\n\nD1 is SQLite. No booleans, no native timestamp type, no jsonb. I ended up with four scripts:\n\n```\n01-export     Supabase → NDJSON\n02-transform  pure, offline, no network\n03-import     NDJSON → D1 REST API (idempotent)\n04-verify     per-table counts + money sums, old vs new\n```\n\nInside the transform, per table I declare which columns are timestamps, date text, JSON or booleans, then coerce:\n\n```\nif (ts.has(k))            out[k] = toMs(v);         // Date → epoch millis\nelse if (dateText.has(k)) out[k] = toDateText(v);   // 'YYYY-MM-DD'\nelse if (json.has(k))     out[k] = toJsonText(v);   // jsonb → TEXT\nelse if (bool.has(k))     out[k] = boolTo01(v);     // true → 1\n```\n\nTwo D1 limits I hit while writing the importer. Maximum SQL statement length is 100,000 bytes, and some of my report rows carry large JSON blobs, so inlining them as SQL text blows past it immediately. Use bound parameters. The cap there is 100 bound parameters per query, so I batch at 90.\n\nThe importer does `INSERT OR REPLACE`\n\nby primary key, so it's idempotent. On cutover day I ran a final delta sync while the old site was still taking writes, then ran it twice more because I didn't trust it.\n\n`db.<ref>.supabase.co`\n\nis IPv6 only. I'm behind Clash with fake-IP DNS, so raw TCP to 5432 just hung, with nothing in the error worth reading.\n\nThe fix is the IPv4 session pooler host, `aws-0-<region>.pooler.supabase.com`\n\n, with the `postgres.<project-ref>`\n\nusername form and `sslmode=no-verify`\n\n. That last flag is for a one-off export script on my own machine, not something to carry into an app connection string. That one cost me an hour.\n\nThe weekly pipeline: scrape ~10 subreddits, run an LLM over the posts to pull out niches, score and rank, generate build kits, persist, publish, queue emails. 500+ seconds end to end.\n\nOn the old stack that was one HTTP invocation fired by a GitHub Actions schedule, all or nothing. If persist failed I lost the collection and the analysis, including LLM tokens I'd already paid for, and re-running meant paying for them again.\n\nCloudflare Workflows persists the result of each `step.do()`\n\n, so a failure retries from the failed step:\n\n```\nexport class NicheDiscoveryWorkflow extends WorkflowEntrypoint {\n  async run(event, step) {\n    const enabled = await step.do('check-enabled', () => isPipelineEnabled(env));\n    if (!enabled) return { success: true, skipped: true };\n\n    const analyzed = await step.do(\n      'collect-analyze-score',\n      { retries: { limit: 2, delay: '30 seconds', backoff: 'exponential' },\n        timeout: '15 minutes' },\n      async () => { /* scrape → LLM → score */ }\n    );\n\n    const report = await step.do(\n      'persist-and-publish',\n      { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } },\n      async () => persistScoredReport(analyzed.scoredTop10, /* … */)\n    );\n  }\n}\n```\n\nTwo things I got wrong on the first pass.\n\nI split steps by module: collect, analyze, score, persist, one each. What matters is what's expensive to redo. Collection is KV-cached and nearly free, the LLM call is where the money goes. So collect + analyze + score collapsed into a single step with the boundary right after it, and now a persist failure never re-spends analysis tokens.\n\nI also passed the raw post array between steps. Step state gets persisted and is size limited, so that falls over as soon as a week's scrape gets big. Steps now hand off the ~10 scored niches and nothing else.\n\nConfig is three lines:\n\n```\n\"triggers\": { \"crons\": [\"0 23 * * 1\", \"0 9 * * *\", \"0 1 * * *\"] },\n\"workflows\": [{\n  \"name\": \"niche-discovery\",\n  \"binding\": \"NICHE_WORKFLOW\",\n  \"class_name\": \"NicheDiscoveryWorkflow\"\n}]\n```\n\nPlus re-exporting the class from your Worker entry, which I forgot, and then spent ten minutes being confused about. First production run took 7 minutes and came out green. I kept the old synchronous endpoint around behind a `CRON_SECRET`\n\nbearer token as a manual fallback.\n\nInbound was the easy one. My Zoho plan was expiring anyway, and Cloudflare Email Routing forwards `support@`\n\nto Gmail for free. Three MX records.\n\nOutbound was harder. Resend's free tier is 100/day and I have around 900 weekly subscribers, so the job is to drain a backlog on a schedule.\n\nThe daily cron takes at most 90, leaving headroom under the limit, ordered paid users first and then by oldest `last_sent_at`\n\n, with a log table so nobody gets the same report twice. Whatever doesn't fit rolls into tomorrow's run. A full cycle takes about ten days, which I'm fine with, because the report is weekly and the people paying for it get it on day one.\n\nRaising the cap later is one secret:\n\n```\necho '1000' | pnpm wrangler secret put NICHE_EMAIL_DAILY_LIMIT\n```\n\nTwo things went wrong.\n\nThe first was DNS error 100117 on deploy. Attaching the apex as a Worker custom domain fails while the old A record still points at Vercel. Deleting the old records and redeploying fixed it, which was obvious afterwards.\n\nThe second was worse. I'd accumulated test-mode payment rows in the new database during the soft launch, so I wrote a cleanup to drop them. One of the rows I classified as test data was a real pending $95 payment from a real customer.\n\nI caught it because the verify script compares money sums per table, not only row counts. The totals didn't reconcile, I went digging, and a full re-import from production put the row back. Reconcile sums per table. A row count can match while the money is wrong.\n\nThe rest of the final verify was clean: 938 users, 8,193 email log rows, and every subscription and payment row reconciled on both count and sum. One expected diff, where D1 had two extra environment kill-switch keys that never existed on the old stack.\n\nThe rollback stayed loaded the whole time. DNS points back at Vercel whenever, Supabase stayed up for two weeks, and because the import is idempotent, recovering writes made during a rollback window is just `03-import --since`\n\n.\n\nI'm a week in and don't have a number worth publishing, so I'm not going to invent one. What I can say is that the app, database, object storage, cache, three cron triggers, the durable pipeline and inbound email are now one Cloudflare account and one `pnpm deploy`\n\n.\n\nThe surprise was Workflows. I migrated for cost and consolidation and came away mostly caring about durable execution. Getting retry and backoff as a config object on a step, instead of hand-rolling a queue with at-least-once semantics, changed what I'm willing to put in a cron job at all.\n\nIf you're planning the same move, the Workflows step design and the D1 importer are the two pieces I have the most to say about. Ask and I'll write them up properly.", "url": "https://wpnews.pro/news/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker", "canonical_source": "https://dev.to/yanhua_wang_4d0f3bfb6f246/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker-3fed", "published_at": "2026-08-11 04:12:55+00:00", "updated_at": "2026-08-11 04:45:26.885308+00:00", "lang": "en", "topics": ["developer-tools", "ai-products", "ai-infrastructure"], "entities": ["ReddTrends", "Vercel", "Supabase", "Cloudflare", "TanStack Start", "Better Auth", "D1", "Resend"], "alternates": {"html": "https://wpnews.pro/news/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker", "markdown": "https://wpnews.pro/news/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker.md", "text": "https://wpnews.pro/news/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker.txt", "jsonld": "https://wpnews.pro/news/migrating-a-live-saas-from-vercel-supabase-to-a-single-cloudflare-worker.jsonld"}}