Part 1: The Problem - No Code Doesn't Fail, It Plateaus#
Nobody regrets starting on no-code. A founder who validated an idea in three weeks on Airtable and Bubble made the correct call, and any engineer who sneers at that is confusing craft with judgment. The tools work. They work right up until the business starts working, and then something uncomfortable happens: every new customer makes the product slower and the invoice bigger, at exactly the moment you wanted the opposite.
The plateau shows up in four predictable places.
The row ceiling. Every no-code database has a hard per-base record limit, and paid tiers buy you a bigger ceiling rather than a different architecture. Approaching it, teams start doing genuinely alarming things: archiving live records into a second base, splitting one logical table across three, deleting history the business actually needs for reporting.
Read latency you cannot fix. A no-code page that loads a filtered view of 40,000 rows is doing work you have no ability to optimise. You cannot add a composite index. You cannot denormalise a counter. You cannot cache a query you did not write. The knobs simply are not exposed, so performance work stops being engineering and becomes negotiation with a vendor's roadmap.
Automation fragility. The logic holding the product together lives in twelve Zapier steps, four Bubble workflows, and a formula field somebody wrote in 2024 and does not remember. You can test the result with browser automation if you are determined, but the logic itself is not version controlled and not unit testable, so a silent failure at step 7 is discovered by a customer rather than by a test.
No real ownership. The moment you want a mobile app, a partner API, a webhook with a signature, or a report that joins three tables, you find that your data is reachable only through an API designed for spreadsheet convenience: rate limited to a handful of requests per second, with types that are suggestions rather than guarantees.
The Three Signals It Is Actually Time#
Plenty of teams migrate too early and waste money. These are the signals that the plateau is real rather than a bad week:
- You are paying for workarounds, not features. The monthly tooling bill is climbing because of connectors, workload overages, and extra seats bought to route around a limit, not because the product got better.
- A feature your customers asked for is architecturally impossible. Not hard. Impossible: a public API, sub-second search across all records, a real audit trail, per-customer branded subdomains.
- Performance is now a support issue. Users mention slowness unprompted. That is revenue leaking, and it is invisible in your analytics.
What Actually Blocks the Decision#
Here is what I hear most often from founders in this position, and it has nothing to do with stacks: "I have 400 paying customers in there. If we get this wrong, I lose the business."
That fear is rational, and it is why so many teams stay on a plateau for two extra years. Rewriting an app is a solved problem. Moving a live app, with real customers mid-session and real money in flight, without a weekend of downtime and without silently corrupting a slice of your rows, is the part that requires actual engineering discipline.
That migration is what this article covers, end to end, with code that runs.
The worked example. Everything below is framed around a rental-booking business: roughly 48,000 records across 9 Airtable tables, a Bubble front end, about 400 active customers, and a combined tooling bill near $700 a month. Numbers in the benchmark section come from this shape of project. Your row counts will differ; the method does not.
Part 2: The Architecture - Strangler Fig, Not Big Bang#
There are two ways to do this, and choosing wrong is how migrations end up on Hacker News.
| Big-bang cutover | Strangler fig (incremental) | |
|---|---|---|
| Downtime | A weekend, optimistically | Minutes, at one planned moment |
| Rollback | Restore a backup and apologise | Flip a flag back, until the writer moves |
| Data loss risk | High: one shot, under time pressure | Low: verified before anything flips |
| Requires feature parity first | Yes, all of it | No, one slice at a time |
| Right for a live revenue app | Rarely | Almost always |
The strangler fig pattern means the new system grows around the old one until the old one is doing nothing, then you remove it. Applied to a no-code migration, it has four phases:
Phase 0 no-code app -----------------------------> users
(source of truth)
Phase 1 no-code app -----------------------------> users
| incremental sync, every 5 min
v
postgres (shadow copy, read by nobody)
Phase 2 no-code app -----------------------------> users
| still the only writer
v next.js app --> users
postgres <---------------------+ (new/read-only screens only)
Phase 3 short read-only banner, final sync, reconcile, flip the writer
next.js app -----------------------------> users
|
v
postgres (source of truth)
Phase 4 no-code base kept read-only as internal admin, then retiredTwo decisions make or break this shape.
Decision 1: One Writer at a Time#
The instinct in phase 2 is dual-write - send every mutation to both systems so they stay in step. Resist it on a small budget. Dual-write is a distributed transaction wearing a friendly name: the second write fails, you now have divergence, and you need a compensating action, a retry queue, and a way to detect the drift you just created. That is a month of work and the bugs are the subtle kind.
The cheaper and safer arrangement is one writer plus frequent idempotent re-sync. The no-code app stays the only thing that mutates data. A job pulls everything modified since the last run and upserts it into Postgres. Because the upsert is keyed on the legacy record id, running it twice is harmless, running it after a crash is harmless, and running it every five minutes keeps the shadow copy within five minutes of truth for anything that was created or edited.
Deletions are the exception, and they are the trap in this design. A deleted record has no last-modified time - it simply stops existing, so a last-modified filter can never return it and an upsert never removes anything. Left alone, Postgres slowly accumulates rows the source no longer has, reconciliation reports them all as extra, and your cutover gate never goes green. The sync therefore needs a second, cheaper pass that fetches ids only and soft-deletes anything that has vanished:
// deletes.ts
export function planSoftDeletes(sourceLegacyIds: Iterable<string>, targetLegacyIds: Iterable<string>) {
const live = new Set(sourceLegacyIds);
const toSoftDelete: string[] = [];
let keep = 0;
for (const id of targetLegacyIds) {
if (live.has(id)) keep += 1;
else toSoftDelete.push(id);
}
return { toSoftDelete, keep };
}
/** A sweep wanting to delete a large share of the table is a broken export, not a busy afternoon. */
export function guardDeleteRatio(toSoftDelete: number, targetTotal: number, maxRatio = 0.02) {
if (targetTotal === 0) return { safe: true, ratio: 0 };
const ratio = toSoftDelete / targetTotal;
if (ratio > maxRatio) {
return { safe: false, ratio,
reason: `sweep would soft-delete ${(ratio * 100).toFixed(1)}% of rows - treat as a failed extract` };
}
return { safe: true, ratio };
}Soft, never hard, and always behind a ratio guard. A row that disappeared because an export glitched is recoverable; a row you DELETEd is gone. With that in place the final cutover costs one short read-only window instead of a month of reconciliation logic.
Decision 2: legacy_id Is the Keystone#
Every migrated row keeps the identifier it had upstream, in a legacy_id text UNIQUE column. This single column buys four things that are painful to retrofit:
- Idempotency.
ON CONFLICT (legacy_id) DO UPDATEmakes re-running the loader free. - Relationship mapping. No-code link fields contain upstream record ids, so
legacy_idis how a link becomes a real foreign key. - Reconciliation. You can diff old against new row by row and name the rows that disagree.
- Rollback. During the overlap, you can always answer "where did this row come from?"
Unique but nullable, and that detail matters: rows created natively after cutover have no upstream identifier, so a NOT NULL here means every insert your new app makes either fails or has to fabricate a fake id. New rows get a uuid primary key from day one. legacy_id is a migration artifact you keep for a year and then drop.
The Target Stack Should Be Boring#
Next.js (App Router) for the app, PostgreSQL for the data, Prisma for access, one managed host, one managed database. No Kubernetes, no microservices, no event bus. The interesting risk in this project is entirely in the data movement, and every exotic infrastructure choice steals attention from the part that can actually lose customer records.
The Schema You Have Never Seen#
Relational databases enforce a schema. No-code bases have habits. The same column holds 1250, 480.5, and "620" because three different people entered data three different ways over two years, and nothing ever objected. If you hand-write your Prisma schema from what the Airtable UI claims the field types are, you will discover the truth at 2am during the load, one type error at a time.
So the first real step is not writing a schema. It is measuring one.
Part 3: The Implementation#
Five steps, in order. All TypeScript in the five toolkit files below is dependency-free and covered by 16 tests that run; the two SQL fragments later on are illustrative and assume whichever Postgres driver you prefer.
Step 1: Extract Without Getting Rate Limited#
Airtable's REST API permits 5 requests per second per base, returns at most 100 records per page, and answers a burst with 429 plus a lockout that makes your "quick export" much slower than pacing yourself would have been. So the extractor paces itself deliberately rather than firing everything at once.
// limiter.ts
export function createLimiter(ratePerSecond: number) {
const interval = 1000 / ratePerSecond;
let nextSlot = 0;
return async function schedule<T>(fn: () => Promise<T>): Promise<T> {
const now = Date.now();
const runAt = Math.max(now, nextSlot);
nextSlot = runAt + interval;
const wait = runAt - now;
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
return fn();
};
}Reserving the slot before awaiting is the important detail: every caller claims a distinct slot up front, so concurrent callers pace themselves against the same ceiling without coordinating. Measured, ten calls at 5/s drain in 1,801ms. In the cursor walk below the requests are sequential anyway, but the same limiter is what keeps a parallel per-table run from collectively blowing the budget.
Pagination is a cursor walk, and retries must distinguish "try again" from "you will never be allowed in":
// extract.ts
export async function* extractPages(opts: ExtractOptions): AsyncGenerator<SourceRecord[]> {
const pageSize = opts.pageSize ?? 100; // documented maximum
const limit = createLimiter(opts.ratePerSecond ?? 5);
let offset: string | undefined;
do {
const url = new URL(`https://api.airtable.com/v0/${opts.baseId}/${encodeURIComponent(opts.table)}`);
url.searchParams.set('pageSize', String(pageSize));
if (offset) url.searchParams.set('offset', offset);
const body = await withRetry(() =>
limit(async () => {
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${opts.apiKey}` },
});
if (!res.ok) {
const err = new Error(`source responded ${res.status}`) as Error & { retryable: boolean };
err.retryable = res.status === 429 || res.status >= 500; // never retry a 401
throw err;
}
return res.json();
}),
);
yield body.records; // yield pages, so each page can be written to disk
offset = body.offset;
} while (offset);
}Yielding pages rather than accumulating everything in memory matters for the same reason it always does, but here it also lets you write each page to disk as raw JSON. Keep that raw export. When reconciliation flags a disagreement three weeks later, the untouched source snapshot is the only thing that can settle the argument.
For the incremental sync in phase 1, filter on a last-modified field and store the high-water mark between runs:
url.searchParams.set(
'filterByFormula',
`IS_AFTER({Last Modified}, DATETIME_PARSE('${lastRunIso}'))`,
);Two prerequisites there, since neither is obvious. Airtable does not expose a record-level modified timestamp through the API unless someone has added a Last Modified Time field to the table, so on a live base that field is step zero of the whole migration. And combining filterByFormula with cursor pagination over a table people are actively editing can skip records mid-walk, because a row edited during the walk can move relative to your cursor. The delete sweep and the reconciliation gate are what catch anything that slips through, which is the real reason both exist.
Step 2: Infer the Schema You Never Had#
Now profile every column across every extracted record: what shapes appear, how often the column is actually populated, and what single Postgres type can hold all of it.
// infer-schema.ts
function shapeOf(value: unknown): PgType | 'null' {
if (value === null || value === undefined || value === '') return 'null';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'numeric';
if (Array.isArray(value)) {
if (value.length === 0) return 'null';
if (value.every((v) => typeof v === 'string' && RECORD_ID.test(v))) return 'link';
if (value.every((v) => typeof v === 'string')) return 'text[]';
return 'jsonb';
}
if (typeof value === 'object') return 'jsonb';
if (typeof value === 'string') {
if (ISO_DATETIME.test(value)) return 'timestamptz';
if (ISO_DATE.test(value)) return 'date';
return 'text';
}
return 'text';
}
/** Widen two competing shapes into one that holds both. */
function widen(a: PgType, b: PgType): PgType {
if (a === b) return a;
const table: Record<string, PgType> = {
'integer|numeric': 'numeric',
'date|timestamptz': 'timestamptz',
'jsonb|text[]': 'jsonb',
'jsonb|link': 'jsonb',
'link|text[]': 'text[]',
};
return table[[a, b].sort().join('|')] ?? 'text'; // incompatible collapses to text
}Two design choices in there are deliberate and worth stating plainly.
Treating '' and [] as null is not sloppiness. Upstream, a cleared cell and an untouched cell are the same thing, and if you import empty strings into Postgres you will spend the next year writing WHERE notes IS NOT NULL AND notes <> '' in every query.
Collapsing genuinely incompatible shapes to text instead of throwing keeps the migration moving. A column you tighten next week is a much smaller problem than a loader that halts on row 31,000 at midnight. What you must not do is let it collapse silently, which is why every profile carries the full list of observed shapes.
Run it on the example bookings table and the report is immediately useful:
| Source column | Column | Inferred type | Nullable | Observed shapes |
|---|---|---|---|---|
| Customer Name | customer_name | text | no | text |
| Total Price | total_price | text | no | integer, numeric, text |
| Deposit Paid | deposit_paid | boolean | yes | boolean |
| Start Date | start_date | timestamptz | no | date, timestamptz |
| Notes | notes | text | yes | null, text |
| Tags | tags | text[] | yes | text[] |
| Customer | customer_id -> FK | link | no | link |
Note how nullability and observed shapes come from different evidence. deposit_paid is nullable because one row omits the field entirely, not because a null was ever observed in it - an absent field is never sampled. notes shows a real null because a row contains an empty string, which is a value the source actually stored. Keeping those two signals separate is what stops you from marking a column non-nullable on the strength of rows that never mentioned it.
total_price is the money shot, and it is exactly the kind of thing that a hand-written schema misses. Somebody typed a price as text. Had you declared that column numeric from the Airtable UI's description of it, the load would have died partway through, or worse, a coercion would have quietly rounded it. The profiler found it in seconds, and now it is a decision you make deliberately: clean the three offending rows, then tighten the column to integer cents.
Emit DDL from the profile rather than writing it by hand:
CREATE TABLE bookings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_id text UNIQUE,
customer_name text NOT NULL,
deposit_paid boolean,
notes text,
start_date timestamptz NOT NULL,
tags text[],
total_price text NOT NULL,
customer_id uuid REFERENCES customers(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- After pass 2 backfills links and reconciliation is clean:
-- ALTER TABLE bookings ALTER COLUMN customer_id SET NOT NULL;Two things about the nullability in there, because both will bite you.
customer_id is a foreign key that was populated on every single source row, so the obvious move is to declare it NOT NULL. Do that and your loader dies on row 1 of 48,000. Pass one inserts rows with links unset - it cannot resolve them yet, because the row it points at may not exist in Postgres. The constraint has to arrive after the backfill, which is why the generator emits that ALTER TABLE as a separate step rather than letting you forget it.
The inferred NOT NULLs on ordinary columns have a subtler version of the same problem. They come from a full scan of data that already exists. During the overlap the no-code app is still live, and a user filling in a form can leave blank something that has never been blank before. Relax inferred NOT NULL on anything a user can leave empty, and add it back after cutover when you control the writes.
Step 3: Load in Two Passes#
Pass one writes rows with no relationships. Pass two wires the relationships up. Trying to do both at once means caring about insert order across nine tables with circular links, which is a bad afternoon.
// load.ts
export function buildUpsert(table: string, profiles: ColumnProfile[]) {
const cols = profiles.filter((p) => p.type !== 'link').map((p) => p.column);
const placeholders = cols.map((_, i) => `$${i + 2}`);
const updates = cols.map((c) => `${c} = EXCLUDED.${c}`);
const sql =
`INSERT INTO ${table} (legacy_id, ${cols.join(', ')})\n` +
`VALUES ($1, ${placeholders.join(', ')})\n` +
`ON CONFLICT (legacy_id) DO UPDATE SET ${updates.join(', ')}, updated_at = now()\n` +
`RETURNING id, legacy_id;`;
const values = (record: SourceRecord): unknown[] => [
record.id,
...profiles.filter((p) => p.type !== 'link')
.map((p) => normalizeValue(record.fields[p.sourceName], p)),
];
return { sql, values };
}That ON CONFLICT (legacy_id) clause is the entire zero-downtime story compressed into one line. It is why the five-minute sync can run unattended for three weeks, why a crashed run needs no cleanup, and why the final cutover sync is a no-op for every row that has not changed.
Pass two resolves link fields through the id map built during pass one, and - this is the part people get wrong - reports what it could not resolve instead of dropping it:
export function resolveLinks(records: SourceRecord[], linkFields: string[], idMap: IdMap) {
const updates: { legacyId: string; column: string; targetId: string }[] = [];
const unresolved: { legacyId: string; column: string; missing: string }[] = [];
for (const record of records) {
for (const column of linkFields) {
const raw = record.fields[column];
if (!raw) continue;
for (const legacyTargetId of (Array.isArray(raw) ? raw : [raw]) as string[]) {
const targetId = idMap.get(legacyTargetId);
if (targetId) updates.push({ legacyId: record.id, column, targetId });
else unresolved.push({ legacyId: record.id, column, missing: legacyTargetId });
}
}
}
return { updates, unresolved };
}Orphaned links are normal, not exceptional. A row deleted upstream while your export was running leaves a dangling reference, and in a live migration that happens on every single run. A loader that swallows those produces a database that looks complete and is quietly wrong. On the example run: 2 links resolved, 1 orphaned, named in the output.
Step 4: Migrate Auth Without Locking Anyone Out#
Here is the constraint that reshapes this step, and it catches almost everyone the first time: you cannot export your users' passwords. No-code platforms hold the hashes and do not hand them over, which means there is no way to move 400 accounts across and have everyone's existing password keep working.
What works, in order of preference:
- Magic-link first login. On day one after cutover, users enter their email and receive a signed, single-use, short-expiry link. They land logged in, then optionally set a password. Nobody is locked out and nobody has to remember anything.
- Forced reset with a pre-seeded token. Email everyone a reset link before cutover. Expect a long tail of people who never click it, so keep option 1 as the fallback rather than treating this as complete.
- Social login, if they used it upstream. Google and Apple identities port cleanly because the identity provider, not your old platform, owns the credential.
// Seed users with no credential, then verify identity by email on first login.
// Conflict target is email, not legacy_id: two upstream records can share an
// address, and it is the email uniqueness you actually need to survive.
await sql`
INSERT INTO users (legacy_id, email, name, email_verified_at, password_hash)
VALUES (${record.id}, lower(${email}), ${name}, ${verifiedAt}, NULL)
ON CONFLICT (email) DO UPDATE
SET name = COALESCE(EXCLUDED.name, users.name),
email_verified_at = LEAST(users.email_verified_at, EXCLUDED.email_verified_at);
`;Two details worth pre-empting. Store emails lowercased behind a unique index (citext or a unique index on lower(email)), because no-code bases cheerfully contain both Ana@x.com and ana@x.com as separate users - and note that this is exactly why the upsert above conflicts on email rather than legacy_id. Keying it on legacy_id would sail past the duplicate and then explode on the unique index, which is the bug the warning is about. Decide which of the two records wins before the index decides for you. Second, carry email_verified_at across, or you will force people who verified two years ago to do it again.
Step 5: Reconcile, Then Flip#
You do not cut over because the loader exited zero. You cut over because a reconciliation report comes back clean twice in a row.
The hard part is comparing values across two systems that represent the same fact differently. "$1,250.00" and 1250 are the same price. ['b','a'] and ['a','b'] are the same set of links. '', null, and [] are all the same absence. A naive !== diff will report thousands of false mismatches, you will stop trusting the report, and the report was your only safety net.
// reconcile.ts
export function canonical(value: unknown): string {
if (value === undefined || value === null || value === '') return '';
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) {
if (value.length === 0) return '';
return [...value].map(canonical).sort().join('|'); // link order is not meaningful
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => canonical(v) !== '') // blank keys must not count
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([k, v]) => `${k}=${canonical(v)}`).join(';');
}
const str = String(value).trim();
const asDate = /^\d{4}-\d{2}-\d{2}/.test(str) ? new Date(str) : null;
if (asDate && !Number.isNaN(asDate.getTime())) return asDate.toISOString();
const asMoney = str.replace(/[$,\s]/g, ''); // "$1,250.00" === 1250
if (/^-?\d+(\.\d+)?$/.test(asMoney)) return String(Number(asMoney));
return str;
}Do not skip the object branch. Without it every object stringifies to "[object Object]", which makes every JSON column equal to every other JSON column, and your reconciliation report comes back clean while the data is wrong. That is a worse outcome than no report at all.
The report itself answers three questions and nothing else: which rows are missing from the new system, which rows exist there that should not, and which rows disagree field by field.
type Row = { legacyId: string; fields: Record<string, unknown> };
export function reconcile(source: Row[], target: Row[], compareFields: string[]): ReconcileReport {
const targetByLegacy = new Map(target.map((r) => [r.legacyId, r]));
const sourceIds = new Set(source.map((r) => r.legacyId));
const missing: string[] = [];
const mismatched: ReconcileReport['mismatched'] = [];
for (const row of source) {
const found = targetByLegacy.get(row.legacyId);
if (!found) { missing.push(row.legacyId); continue; }
for (const field of compareFields) {
if (canonical(row.fields[field]) !== canonical(found.fields[field])) {
mismatched.push({ legacyId: row.legacyId, field,
source: row.fields[field], target: found.fields[field] });
}
}
}
const extra = target.filter((r) => !sourceIds.has(r.legacyId)).map((r) => r.legacyId);
return { sourceCount: source.length, targetCount: target.length, missing, extra,
mismatched, clean: !missing.length && !extra.length && !mismatched.length };
}With that gate in place, cutover is anticlimactic, which is the goal:
- Put the no-code app in read-only mode and show users a short maintenance banner. Pick a genuinely quiet hour, and know when that is from your own analytics rather than guessing.
- Run the final incremental sync. Only rows touched since the last run move, so this takes seconds.
- Run reconciliation. If it is not clean, lift the banner and go back to phase 2. Nothing is lost by aborting, which is exactly why this is safe.
- Flip the read-and-write flag to Postgres.
- Lift the banner. Watch error rates for an hour with the old system still intact behind you.
Be precise about where the safety actually ends, because this is the one place a migration guide can lie to you by omission. Steps 1 through 3 are freely abortable: the no-code app is still the writer, so walking away costs you nothing but a few minutes of read-only time. Step 4 is the point of no return. Once Postgres accepts the first write, there is no reverse sync, and "flip the flag back" would silently discard every customer write since the flip. Keeping the old system intact afterwards buys you a data source to investigate with, not an undo button. If you want a true post-flip rollback you have to build reverse sync, which costs more than doing steps 1 to 3 carefully enough that you never want it.
Total user-visible downtime on the example project: under three minutes, at 4am local time.
Part 4: Benchmarks and the Pitfalls Nobody Warns You About#
Extract Throughput Is Bounded by Someone Else's Rate Limit#
Offset pagination is strictly sequential - you cannot know page N+1's cursor until page N comes back - so the rate limit sets a floor and per-request latency sets the real time. Both matter, and the arithmetic is worth doing before you promise anyone a timeline:
| Records | Requests at 100/page | Floor at 5 req/s | Realistic at ~400ms/req |
|---|---|---|---|
| 5,000 | 50 | ~10 s | ~20 s |
| 48,000 | 480 | ~96 s | ~3.2 min |
| 250,000 | 2,500 | ~8 min | ~17 min |
So a full 48,000-row extract is minutes, not hours. This is why the "export takes forever" complaint is nearly always an unthrottled script tripping a rate-limit lockout rather than a data-volume problem.
One caveat the docs bury: Airtable's list iterator goes stale if you dawdle between pages, and a stale offset returns 422 LIST_RECORDS_ITERATOR_NOT_AVAILABLE. That status is correctly classified as non-retryable by the extractor above, but it means a slow run can die mid-table. If your tables are large enough for that to be a real risk, page on a stable sort plus a record-id watermark instead of trusting the cursor to survive.
Before and After#
Measured on the example project, comparing the no-code stack against Next.js and PostgreSQL on managed hosting:
| Metric | Before (no-code) | After (Next.js + Postgres) |
|---|---|---|
| Bookings dashboard, 40k rows | 6–9 s | 180–400 ms |
| Search across all records | Not available | Indexed, sub-100 ms |
| Monthly platform cost | ~$700 | ~$45 |
| Record ceiling | Hard tier limit | None that matters at this scale |
| Public API | No | Yes |
| Automated tests | Awkward at best | 16 in the migration toolkit alone |
The latency change is not clever engineering. It is an index on a column you were finally allowed to create.
The Cost Question, Answered Directly#
The old bill was not one product, it was a stack of workarounds: extra seats bought to route around a limit, a connector tier bought to move data between two tools, workload overages bought because the app got popular.
| Monthly | |
|---|---|
| No-code base, 4 seats at business tier | ~$180 |
| App platform, growth tier plus workload overage | ~$300 |
| Automation connector, professional tier | ~$70 |
| Assorted plugins, email, form tools | ~$150 |
| Before | ~$700 |
| Managed hosting (Next.js) | $20 |
| Managed PostgreSQL | $19 |
| Transactional email | $0–15 |
| After | ~$45 |
That is roughly $7,860 a year back. Against a migration in the $4,000–$9,000 range, payback lands somewhere between seven and fourteen months, and then it keeps paying. Verify current vendor pricing yourself; plans change, and the point is the shape of the bill, not the exact figures.
The subtler win is that the old bill grew with usage while the new one mostly does not. A no-code stack charges you more for succeeding. A $19 Postgres instance will carry this workload to several times its current size before it needs a thought.
Nine Pitfalls, In Rough Order of How Much They Hurt#
- Attachment URLs expire. Airtable's attachment links are short-lived. If you store the URL instead of downloading the file during migration, you will ship a database full of dead image links and discover it weeks later. Download every file to your own object storage as part of the load, and store your key.
- Rows get deleted mid-export, and upserts never notice. Live systems change under you, so any real run produces orphaned links and stranded rows. Report orphans, sweep deletes by id, and guard the sweep with a ratio check so a broken export cannot wipe the table.
- Empty string is not null. Normalise on the way in. This is a five-line function that saves a year of defensive
WHEREclauses. - Money in a text column. Never migrate currency into a float. Clean the offenders, store integer cents. Trust the profiler over the field label.
- Timezones. No-code tools often display and store local time with no offset. Postgres wants UTC. Decide the source timezone once, convert explicitly, and check a booking that crosses a DST boundary.
- Inferred
NOT NULLbreaks during the overlap. Discussed above: relax it until you own the writes. - Undocumented automations. Somewhere a Zapier step is emailing a customer, and nobody remembers building it. Inventory every automation before cutover and decide which ones move, which get rewritten, and which quietly get killed. Turning the old system off is how teams discover a workflow the business depended on.
- Lost SEO on public pages. If the no-code app served public URLs, keep them. Map every old path to its new one and serve
301s. Replatforming and silently changing every URL is how a migration that succeeded technically shows up as a revenue drop. - Case-duplicate emails. Two accounts differing only in capitalisation will fight your unique index. Resolve them before the index exists, not during the load.
Part 5: Key Takeaways#
Migrate incrementally, always. A live app with paying customers does not get a big-bang rewrite. Mirror, sync, verify, flip. Every phase before the writer moves must be abortable without loss, because the migration you can safely walk away from is the one that succeeds.
Keep legacy_id on every row. One unique column carrying the upstream identifier gives you idempotency, relationship mapping, reconciliation, and rollback. It costs nothing and it is close to impossible to add later.
Measure the schema before you write it. No-code data does not obey the field types its UI advertises. Profile every column across every row before writing a line of DDL, and let the profiler tell you where two years of inconsistent entry are hiding.
One writer beats dual-write on a budget. Frequent idempotent re-sync plus a delete sweep gets you a three-minute cutover window for a fraction of the complexity, and none of the divergence bugs.
Treat reconciliation as the gate. Compare canonicalised values so the report is trustworthy, and only flip when it comes back clean twice.
Migrate the part that hurts, keep the rest. This is the point most migration guides miss. The customer-facing app needs to move because that is where latency and cost bite. Your internal ops board, the sales pipeline, the content calendar - those can happily stay on no-code forever. Moving only the surface your customers touch is usually the right call, and it takes a large bite out of the bill for a fraction of the work and risk of a full migration.
What This Costs in Practice#
A migration like the one above - full extract, schema inference, loader, auth migration, a Next.js app at parity for the customer-facing surface, and a verified cutover - lands between $4,000 and $9,000 depending on table count and how much custom logic is buried in the automations. Simpler apps sit lower; anything with payments, multi-tenancy, or a complex permission model sits higher. Two to five weeks, most of it spent on parity and automation archaeology rather than on the data movement itself.
That is well below typical agency pricing for the same scope, and the reason is not heroics. The method is boring, the toolkit is reusable across projects, and most of the risk lives in data movement that has already been solved once. A shop staffing this with three people and a discovery phase has to charge for three people and a discovery phase.
The Question You Should Actually Ask Me#
Not "how much." "Who maintains this afterwards?"
That is the real reason to stay on no-code, and it deserves a straight answer rather than a sales one. After cutover you own a codebase, a database, and a deploy pipeline - which is the point, since that ownership is what removes the ceiling. But it does mean something breaks eventually and somebody competent has to care. Plan for that before you migrate, not after: budget a small monthly retainer for updates and the occasional 2am problem, or make sure the handover includes documentation good enough for the next developer you hire to be productive in a week. A migration delivered without either is a bill you will pay twice.
If a retainer is not in the budget, that is a legitimate reason to migrate only part of the system, or to wait. I would rather tell you that than sell you a codebase nobody is looking after.
Sitting on a no-code app that has outgrown its platform? I build and migrate these for founders on real budgets - tell me what you are running and I will tell you honestly whether it is time to move, and what it would take.
Share this technical insight with your network
Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.
Related Technical Articles
View all articles →
How I Built Imperial Votes: A Multi-Tenant Competition SaaS on Next.js & Prisma in 8k
A build breakdown of a multi-tenant competition platform on Next.js and Prisma: tenant isolation, fraud-resistant paid voting, and deadline-spike survival.

Building AI Features in Laravel Without Turning Your Application Into a Mess
AI features can quickly turn a clean Laravel application into a maintenance nightmare. Here's how to integrate AI without sacrificing architecture.
Have a complex technical project in mind?
Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

