Written by Huifer, solo developer and maintainer of TanStack Ship. I shipped three spreadsheet-to-SaaS conversions in 2025–2026: a 47-sheet inventory tool, a 12k-row pricing analyst, and a clinic scheduling tool that replaced a Google Sheets handoff between four receptionists. Each migration followed the same seven-stage shape and hit the same three tradeoffs. This is that playbook — stage by stage, with the code I ship on TanStack Ship and the "when this breaks" notes.
Verified sources: Google Sheets API quotas · Microsoft Excel import limits · Stripe Billing metering · Better Auth · Cloudflare D1 limits · OWASP A01 Broken Access Control · TanStack Start · CSV RFC 4180
Last updated: 2026-05-14 · Changelog
Spreadsheet to SaaS: 7 Stages and 3 Honest Tradeoffs
TL;DR: I shipped 3 spreadsheet-to-SaaS migrations in 2025–2026. The shape: inventory the spreadsheet, pick the data model, ship UX parity then UX improvement, wire auth + billing, migrate in waves, comms + sunset, measure. The three honest tradeoffs: speed-to-ship vs UX parity, the 20–30% of spreadsheet users who never convert, and edge cases that should stay in formulas forever. Stages 0–3 take a weekend; 4–6 take 2–3 weeks; 7 is forever.
When the spreadsheet stops scaling (the honest triggers)
Most founders wait too long. By the time the spreadsheet has five editors and a 30-second save delay, the technical debt is already compounding. Three triggers I tell founders to watch for — none is "the file got too big."
5 seats editing the same file
A Google Sheets API cell-write race produces silent overwrites at 5 concurrent editors. Signal: "I edited a row and two minutes later it reverted." Your data layer is no longer trustworthy.
Audit trail requirements
Spreadsheets have no audit log. The moment a customer, accountant, or regulator asks "who changed this row on March 14?" you need append-only event logs with timestamps and actor IDs. See the SaaS authentication guide for the session-id pattern.
Per-customer row counts > 5,000
Past 5,000 rows, VLOOKUP times out, pivot tables crawl, edits become lossy. The 12k-row pricing analyst had a 14-second recalc on a single cell change. Cloudflare D1 with a typed schema runs the same queries in single-digit milliseconds.
Stage 0: Inventory the spreadsheet (what you are actually shipping)
The first stage is the one founders skip. They see "Sheet1, Sheet2, Sheet3" and assume the migration is straightforward. It is never straightforward. A 47-sheet inventory file I migrated had 11 sheets no one had edited in 18 months.
Cells, formulas, named ranges
Audit every sheet, named range, and formula. I use a script that walks the Google Sheets API and emits a JSON summary: sheet name, last edit, last editor, formula count, external link count. That summary is the source of truth for "what migrates."
VBA / Apps Script / Sheets Macros
Hidden complexity. A 12k-row pricing analyst had 47 Apps Script triggers that fired on cell change. Enumerate them before migration, or you will spend a month reproducing them. List, classify, decide what to rebuild vs drop.
External data pulls and queries
IMPORTRANGE, IMPORTDATA, QUERY — every formula that reaches outside the file is a hidden dependency. The SaaS replacement needs an explicit data-source integration, not an in-cell import.
Stage 1: Pick the data model before the framework
The most common migration mistake is reaching for the framework first. Pick the data model first. A spreadsheet is a relational database that lost its schema; your job in Stage 1 is to recover the schema.
Entities, not tabs
A sheet called "Q3_inventory" is not an entity. The entity is Product, Warehouse, StockMovement. Map every sheet to entities; map every column to a typed field. Use the schema as the CSV RFC 4180 import contract so you can re-import cleanly if the migration fails.
Schema validation at the edge
Validate at the edge, not in the database. A TanStack Start server function validates an incoming row against a Zod schema in <2 ms and rejects before write. Saves you from cleaning bad rows out of D1.
Migration paths: CSV / SQL dump / live sync
Three options for initial load: CSV export + import, SQL dump from Postgres staging, or live sync via the Google Sheets API. For <50k rows, CSV is fastest. The TanStack Ship importer ships as a single server function:
// src/server/import/sheets-to-d1.ts
import { z } from "zod";
const ProductRow = z.object({
sku: z.string().min(1).max(64),
name: z.string().min(1).max(200),
warehouse_id: z.string().uuid(),
on_hand: z.number().int().min(0),
unit_cost_cents: z.number().int().min(0),
});
export type ProductRow = z.infer<typeof ProductRow>;
export async function importProducts(rows: unknown[], tenantId: string) {
const validated = z.array(ProductRow).parse(rows);
const stmt = db.prepare(
`INSERT INTO products (id, tenant_id, sku, name, warehouse_id, on_hand, unit_cost_cents, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch())
ON CONFLICT (tenant_id, sku) DO UPDATE SET
name = excluded.name,
on_hand = excluded.on_hand,
unit_cost_cents = excluded.unit_cost_cents,
updated_at = excluded.updated_at`
);
const batch = validated.map((r) =>
stmt.bind(crypto.randomUUID(), tenantId, r.sku, r.name, r.warehouse_id, r.on_hand, r.unit_cost_cents)
);
await db.batch(batch);
return { imported: validated.length };
}
The ON CONFLICT clause means re-running the import is idempotent. The tenant_id is the multi-tenant boundary anchor — every row is scoped to one workspace.
Stage 2: UX parity, then UX improvement
Resist shipping the better version first. Spreadsheet users have muscle memory; the better version loses trust. Ship UX parity first, then layer UX wins in 2-week waves.
What you keep
Column ordering, same field names (rename them later), keyboard shortcuts if you can.
What you cut
Cells no one edited in 18 months (you inventoried them in Stage 0). The "Notes" column with 6 formats. Summary tabs no one looked at.
What you rebuild from scratch
The search bar — VLOOKUP is a search bar with a bad UI. The filter — pivot tables are filters with a bad UI. The audit log — the spreadsheet never had one. These three rebuilds justify the migration.
Stage 3: Auth, billing, and the "per-customer" trap
Stage 3 is where most migrations die. Get these wrong and you ship a demo.
Better Auth over Clerk for solo SaaS
For solo-developer SaaS shipping off a spreadsheet, Better Auth is the right choice over Clerk. It runs on your stack, has no per-MAU pricing that punishes growth, and matches the spreadsheet's "one Google account per user" mental model. Wire it with email + Google OAuth — what spreadsheet users already have.
// src/server/auth/config.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/server/db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
emailAndPassword: { enabled: true, requireEmailVerification: true },
socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! } },
user: { additionalFields: { tenant_id: { type: "string", required: true } } },
});
Stripe per-seat vs flat-rate
Three options: per-seat (collaboration tools), flat-rate (solo or small-team), usage-metered (when the spreadsheet had a usage column). For the inventory tool I shipped, flat-rate $29/mo per warehouse won because editors were always 2–3 — per-seat would have doubled their bill without changing the value. The Stripe Billing metering docs cover the API surface; the SaaS pricing models guide covers when each model wins.
Tax, invoicing, refunds
Spreadsheets do invoicing in another column. SaaS does it through Stripe. The Stripe Tax integration handles US sales tax, EU VAT, and UK VAT without writing a single line. Enable the Stripe Customer Portal on day one.
Stage 4: Migrate in waves, not big bang
The big-bang migration is the single biggest failure mode. It looks like a weekend project and becomes a six-week fire. Migrate in three waves.
Wave 1: read-only mirror
Build a read-only mirror of the spreadsheet in the SaaS. Users can read but not write. Run for 7 days. Find every surprise. Fix. The TanStack Ship template ships a one-shot CSV exporter for this wave:
// src/server/export/csv.ts
import { stringify } from "csv-stringify/sync";
import { db } from "@/server/db";
export async function exportProductsCSV(tenantId: string): Promise<string> {
const rows = await db
.prepare(`SELECT sku, name, on_hand, unit_cost_cents FROM products WHERE tenant_id = ? ORDER BY sku`)
.all(tenantId);
return stringify(rows, { header: true });
}
Returns a clean RFC 4180 CSV that drops straight into Google Sheets as the Wave 1 mirror.
Wave 2: dual-write with validation
Run the spreadsheet AND the SaaS in parallel. Every write goes to both, with the SaaS validating the schema. Run for 14 days. Compare rows nightly; reconcile mismatches. Longest wave; budget for it.
Wave 3: cutover with rollback
Cutover at a known low-traffic time. Keep the spreadsheet editable for 72 hours as a rollback path. Then read-only for 30 days. Then archive. The Cloudflare D1 limits page documents the storage caps if you forget to archive.
Stage 5: Tell the spreadsheet users before you kill the spreadsheet
Most founders ship the SaaS and forget the humans. Then 30% keep editing the spreadsheet and you have two sources of truth.
Comms plan
Email users 30, 14, 7, and 1 day before cutover. Each email says: what is changing, what is staying, where to ask questions. For small teams, send a 5-minute Loom per workflow.
Sunset date
Pick a sunset date and announce it. "After June 14 the spreadsheet will be read-only." Without a date, "soon" becomes "never."
Export endpoint forever
Ship an export endpoint that lets any user download their data as CSV. Forever. Spreadsheet users trust their data because they can take it.
Stage 6: Measure what you actually shipped
A migration is not done when the SaaS launches — it is done when the spreadsheet is empty and tickets trend down.
30-day retention
What percent of spreadsheet users logged in by week 1, week 2, week 4? Goal: ≥70% in week 1, ≥80% in week 4. Below 70% means comms failed.
Time saved per workflow
Time the highest-volume workflow. Before: 14 minutes per cycle. After: 4. The ROI number on the pricing page.
Support tickets per workflow
Every ticket in the first 30 days is a UX gap. Triage by frequency; fix the top three before week 4. The clinic scheduling migration had 18 tickets in week 1, 4 by week 4.
The 3 honest tradeoffs
No spreadsheet-to-SaaS migration is free. The three tradeoffs I name on day one. They are not bugs; they are features of the medium change.
Tradeoff #1: Speed to ship
UX parity (Stage 2) costs 2–3 weeks founders want to spend on the "better" version. Ship parity, data model, then UX wins in waves. The 12k-row pricing analyst took 11 weeks; without parity-first it would have taken 16 and lost the customer.
Tradeoff #2: Spreadsheet users who never convert
20–30% of spreadsheet users will not log in — not because the SaaS is bad, but because the spreadsheet was theirs in a way the SaaS will never be. The export endpoint is for them. Do not let them anchor your retention metric.
Tradeoff #3: Edge cases in formulas forever
Every spreadsheet has a formula that handles an edge case nobody else understands. The inventory tool had a cell that calculated "do we have enough for the holiday rush" using a regression on 18 months of history. That formula never moved into the SaaS — it lives in a Notion doc, run manually each November. Some edge cases should stay in formulas.
When NOT to migrate
The migration is wrong if the spreadsheet is doing one of these jobs:
- One-shot calculations — a weekly pricing analyst that produces a PDF. That is a script, not a SaaS.
- Two-person handoffs — two editors is not enough scale.
- Compliance-by-spreadsheet — if "no SaaS will pass our audit," the migration is to find a compliant SaaS, not build one.
For everyone else, the seven stages ship on TanStack Ship as a typed template. The pricing page is honest; the comparison page lists every module; the blog index has the postmortems.
FAQ
How long does the migration take?
Single spreadsheet, <10 sheets, <5k rows: 3–4 weeks. Larger (30+ sheets, 10k+ rows): 8–12 weeks. Variance is almost entirely in Stages 2 and 4.
Can I keep Google Sheets as the source of truth?
You can, but you should not. Sheets becomes a write-only mirror or CSV export. Two sources of truth is the failure mode.
What about Notion / Airtable / Coda?
Spreadsheets in disguise. If your team uses Notion as a database, the target is a real SaaS with a typed schema.
Do I need a co-founder?
No. All three migrations were solo. The stages are sequential — no parallel workstreams needed.
Conclusion
The shape works. Inventory, data model, UX parity then improvement, auth + billing, wave migration, comms + sunset, measure. Name the tradeoffs out loud. The 20–30% who never convert, the formulas that never migrate, the speed-to-ship cost — the price of medium change.
Ready to ship your migration? TanStack Ship ships a typed spreadsheet importer, Better Auth, Stripe billing, and 14 production modules on Cloudflare Workers. See pricing for the live template or compare for the full module list — and browse the blog for the architecture guides and postmortems that informed each stage.