Migrating a live SaaS from Vercel + Supabase to a single Cloudflare Worker 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. 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. Last Tuesday I moved it off Next.js on Vercel + Supabase and onto one Cloudflare Worker. Same domain, Creem subscriptions live the whole time. Four things were worth writing down: the password hashes, the D1 import, the weekly pipeline, and one payment row I deleted by accident. | Before | After | | |---|---|---| | App | Next.js on Vercel | TanStack Start + React 19 on Cloudflare Workers | | DB | Supabase Postgres | Cloudflare D1 SQLite | | Auth | Supabase Auth GoTrue | Better Auth | | Weekly AI pipeline | GitHub Actions cron → one long HTTP endpoint | Worker Cron Trigger → Cloudflare Workflows | | Daily emails | GitHub Actions cron | Worker Cron Trigger | | Inbound mail | Zoho | Cloudflare Email Routing | | Outbound mail | Resend | Resend unchanged | | Files / cache | — | R2 + Workers KV | Row 3 is the one I cared about. The rest came along for the ride. I 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. Leaving 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. You can just override the verify function: python import bcrypt from 'bcryptjs'; import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto'; // GoTrue emits $2a$; bcryptjs emits $2b$; $2y$ is the PHP variant. const BCRYPT HASH = /^\$2 aby \$/; export async function verifyPasswordCompat { hash, password } { if BCRYPT HASH.test hash { return bcrypt.compare password, hash ; } return verifyScryptPassword { hash, password } ; } betterAuth { emailAndPassword: { password: { verify: verifyPasswordCompat }, // verify only, never hash }, } ; This works on Workers because bcrypt compare pulls 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. It costs 100-250ms of CPU per login for those users. For something that happens once a session I decided I didn't care. D1 is SQLite. No booleans, no native timestamp type, no jsonb. I ended up with four scripts: 01-export Supabase → NDJSON 02-transform pure, offline, no network 03-import NDJSON → D1 REST API idempotent 04-verify per-table counts + money sums, old vs new Inside the transform, per table I declare which columns are timestamps, date text, JSON or booleans, then coerce: if ts.has k out k = toMs v ; // Date → epoch millis else if dateText.has k out k = toDateText v ; // 'YYYY-MM-DD' else if json.has k out k = toJsonText v ; // jsonb → TEXT else if bool.has k out k = boolTo01 v ; // true → 1 Two 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. The importer does INSERT OR REPLACE by 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. db.