How do I migrate an existing SaaS into supastack?
It depends on your codebase, but the sequence does not change: migrate in stages, never in one rewrite. Inventory the repo into product code (routes, schema, domain logic — it stays yours) and infrastructure (auth, tenancy, billing, jobs, email — the kit replaces it). Adopt modules in dependency order: project structure, then Better Auth, then workspaces, then billing, then background work. Move data with a re-runnable script, preserve Stripe customer IDs, and cut over after local acceptance passes. A single web app typically takes days of evenings, not weeks.
Take inventory before you touch code
Every migration that goes badly started the same way: somebody opened the kit repo and began deleting files. The kit is a foundation, not a theme — it replaces your infrastructure, and it has no opinion about your product. Before you move anything, split your repository into the two piles honestly.
Product code is everything a customer could describe back to you: your routes, your schema, your domain logic, your copy, your pricing page. Infrastructure is everything they never see: sessions, tenancy checks, payment webhooks, queued jobs, transactional mail. The first pile migrates intact. The second pile gets replaced — which is the entire point.
- List every route group and label it product or plumbing — be ruthless; a settings page that only toggles a webhook URL is plumbing.
- List your env vars and mark which ones configure infrastructure (auth secrets, provider keys) versus product behavior.
- List your tables and mark which ones the kit will own (users, sessions, workspaces, billing state) versus which are yours.
- Write down the one user journey that pays you — that flow is the acceptance test for every stage below.
Map each seam to a kit module
The kit’s infrastructure is a set of modules with contracts between them — your job is to point each piece of your old plumbing at the module that now owns it. Most of the mapping is one-to-one; the places where it isn’t are where you make real decisions.
Two mapping choices deserve actual thought rather than mechanical porting. If you are coming from Prisma, the kit standardizes on Drizzle — you can keep Prisma for product tables during the transition, but plan to converge; two ORMs in one codebase is a maintenance tax that compounds. The Prisma vs Drizzle comparison lays out what changes.
And if you are coming from a managed auth vendor, resist the urge to replicate every vendor feature before cutting over. Managed auth quietly bundles things your product may not use — session lists, organization UIs, webhook sync. Map what your product actually calls; the auth comparison covers the capability differences that matter.
| Your current plumbing | supastack module | What carries over |
|---|---|---|
| NextAuth.js / Auth.js sessions | Auth (Better Auth) | Users and accounts migrate; sessions are re-issued at cutover |
| Clerk or a managed auth vendor | Auth (Better Auth) | Users migrate by email; third-party sessions are abandoned, not replayed |
| Custom org/team tables | Workspaces | Your org rows become workspace + membership rows |
| Hand-rolled Stripe webhooks | Billing | Stripe customer IDs survive; subscription state is re-derived |
| CRON + setTimeout jobs | Jobs (Trigger.dev) | Scheduled work moves behind the shared JobRunner contract |
| Nodemailer / ad-hoc mail | Templates move; sending goes through one provider adapter |
Adopt modules in dependency order
Migration order is not a preference — it is a dependency graph. Each stage below is separately shippable, and each produces a working system you can verify before the next one starts. The temptation is to do the whole thing in one weekend and debug the intersection; resist it — a staged migration means every bug you meet belongs to exactly one module, which is a debugging superpower you will not appreciate until you skip it.
- Stage 1 — skeleton: scaffold the kit, configure env, drop your product routes into the project structure. Nothing is wired yet; the app boots and your pages render.
- Stage 2 — auth: move sign-in onto Better Auth, migrate the user/account tables, verify sign-in + sign-out end to end. Every later stage depends on a session existing.
- Stage 3 — tenancy: adopt the workspace model, backfill
workspaceIdon your product tables, enforce scoping in reads and writes. This is where the workspace boundaries post earns its keep. - Stage 4 — billing: point checkout and webhooks at the billing module, remap existing subscriptions, verify entitlement checks against real Stripe data in test mode.
- Stage 5 — background work and email: port jobs onto the JobRunner contract and move transactional mail onto the email adapter. These are last because they depend on users and workspaces already existing.
Move the data honestly
Data migration is where migrations earn their reputation. The rules that keep it boring: write a script you can run twice, never mutate the source until cutover, and verify counts instead of trusting the script.
Users and accounts land in the Better Auth schema — email is the join key, and password-based accounts need a decision: either your existing hash format is one Better Auth can verify, or you ship a reset-password flow at cutover and treat it as a forcing function. Third-party OAuth accounts are the easy case — the provider re-issues them on next sign-in.
For billing, the honest path is re-derivation rather than copying rows: keep the Stripe customer ID, let the kit’s webhook handling rebuild entitlement state from the provider’s truth, and diff what it computes against your existing subscription records. The kit’s idempotent webhook ledger exists precisely so replayed historical events do not double-provision anything.
// migration scripts are boring by design: upsert-shaped,
// re-runnable, and they verify counts instead of trusting themselves
for (const legacyUser of legacyUsers) {
await upsertAuthUser({
email: legacyUser.email.toLowerCase(),
name: legacyUser.displayName,
externalId: legacyUser.id, // preserved for one-shot joins
});
}
const migrated = await countAuthUsers();
if (migrated !== legacyUsers.length) {
throw new Error(`count mismatch: ${migrated} of ${legacyUsers.length}`);
}What stays yours
It is worth saying plainly, because it is the part people get wrong in both directions: your product routes, your schema, your domain logic, and your design do not change. You are not rebuilding your app inside the kit — you are setting your app down on top of it.
The inverse mistake is keeping old plumbing alive 'temporarily' next to the new module. A second session check, a second webhook handler, a second entitlement table — every 'temporary' parallel system is permanent until someone deletes it, and each one is a future incident. Replace, verify, delete. If a stage cannot delete the old path yet, it is not finished.
The cutover checklist
Cutover is a checklist, not an event. Run it in a staging environment at least once with production-shaped data before the real date.
- Re-run the data script against a fresh production snapshot; counts and spot-checks pass.
- Sign in with each auth provider your product supports — including the ones you forgot existed.
- Buy your own product in Stripe test mode and follow the webhook through to entitlement.
- Send every transactional email once; check links land on the new deployment.
- Verify the paying journey end to end, then flip DNS/env and watch the first real session.
- Keep the old deployment running for a week — rollback is a DNS change, not a migration.
Common questions about migrating into a starter kit
The same handful of questions come up every time someone sits down to do this.
- How long does a migration actually take? — For a single web app with standard auth + Stripe: days of evenings, not weeks. Multi-tenant B2B with custom billing adds time in stage three and four — the data work is what stretches, never the file moves.
- Should I migrate or start fresh and port features? — If your product code is smaller than your infrastructure debt, starting fresh on the kit and porting product routes is often faster. If your schema and domain logic are the hard part, migrate in place.
- Can I keep my existing ORM / auth library during the transition? — Briefly, yes — one module boundary at a time. Permanently, no: two of anything in the infrastructure layer is a compounding tax.
- What about my Stripe webhooks already in flight? — Point the existing webhook endpoint at the kit’s handler once stage four is verified; the idempotent ledger makes replayed or duplicated events safe instead of dangerous.
- Do I have to adopt every module? — No. Mobile, desktop, jobs, and AI are surfaces to adopt or ignore — the license is the foundation, not a mandate to ship an app per platform.