Convex to Neon: The Playbook After 4 App Migrations

TL;DR
We ran the same Convex to Neon migration on four apps in a week.
Official Sources#
| Topic | Primary source |
|---|---|
| Convex import and export | Convex import and export |
Convex file storage (_storage) | Convex file storage |
| Neon connection and branching | Connect to Neon |
| Drizzle migrations | Drizzle migrations |
Last updated: May 31, 2026. Verify tool behavior and migration commands against the official docs before you ship.
A week ago I wrote up the Convex to Neon migration on a single app, dd-clipper, as a practical notes post. Five tables, five PRs, a planning doc, and a list of lessons we paid for in time.
Tonight three more apps shipped the same migration in one sitting. dd-skills-marketplace, dd-hooks-directory, dd-mcp-directory. A fourth, adcraft-ai, is in flight. One app is an anecdote. Four apps is a pattern.
This post is the sequel. It pulls the moves that worked on dd-clipper out of the war story and writes them down as a reusable playbook. What stayed identical across every app. What was actually app-specific. And the part everyone wants the number for, how much faster the second migration was than the first.
Why this post exists#
The first migration is a project. You discover the shape as you go. You write a planning doc, you find out which guarantees you were quietly relying on, you get burned by a credit deduction race, you realize file storage is a separate problem.
For the implementation path around this, pair it with How to Build Full-Stack TypeScript Apps With AI in 2026 and The Next.js AI App Stack for 2026; those guides connect the idea to a shippable TypeScript stack.
The second migration is a checklist. You already know what useQuery reactivity replaces with. You already know UPDATE ... RETURNING is the credit pattern. You already know the deprecated convex/ directory stays in the repo for a release. You already know what the README cutover note looks like.
The third and fourth are template work. Lift the schema layout, point an agent at the table list, run the playbook, ship. That is the speed-up. Not a clever trick, just the normal compounding you get when you stop solving the same problems twice.
For portfolio context, see the DD apps overview and the stack comparison page. For the tooling story behind running four migrations in one night, the overnight agents post covers how the agent fan-out actually worked.
The seven step playbook#
Every one of the four migrations followed the same seven steps in the same order. This is the version that is now copy-pasted into a checklist file at the top of each migration PR.
- Inventory. List every Convex table, every consumer of every table, every callsite of
useQuery,useMutation, and_storage. This becomes the planning doc. Half a page is fine. The point is that nothing surprises you in step five. - Schema first. Write the Drizzle schema for every table before touching application code. Migrations file, types, exports, done. You should be able to
pnpm db:pushagainst a fresh Neon branch and see the empty tables come up. - Lazy proxy. Add a
db/module per table with the same shape the oldconvex/<table>.tsexported. Import lazily so the dev server does not crash on missing env vars in unrelated routes. This is the part most people skip, and it is the reason migrations stall in review. - Replace callsites table by table. One PR per table where you can. Inside each PR, the old
convex/<table>.tsdoes not get deleted, it gets a// @deprecatedbanner and an empty body that throws a clear error if anyone still imports it. Generated client types stay valid, downstream SDKs do not break. - Atomic writes audit. Every place the old code relied on Convex single-writer semantics gets rewritten as a single SQL statement.
UPDATE ... RETURNING,INSERT ... ON CONFLICT,SELECT ... FOR UPDATEinside a transaction. No SELECT-then-UPDATE patterns survive review. - Reactivity decision. Before merging the first user-facing PR, write down what replaces
useQuery. Polling interval, optimistic updates, server-sent events, or "we accept staleness." This is a one paragraph decision. Do not skip it and do not defer it. - README cutover note. Last commit on the migration branch updates the README with the new connection variable, the new migration command, and a note that
convex/is deprecated and removed on the next release. Without this, the next person who clones the repo runsnpx convex devand is confused for an hour.
That is the whole playbook. Seven steps, no novelty, all boring on purpose.
What was identical across all four apps#
Four things were exactly the same in every migration. Word for word in some cases. These are the parts you can lift verbatim.
The lazy proxy pattern. Every db/ module wraps Drizzle calls in a function that constructs the client on first call, not at import time. Without this, any route that imports a sibling module gets an "env var missing" crash in dev when you have not yet wired up DATABASE_URL for that route. The pattern is three lines and it shipped unchanged in dd-clipper, dd-skills-marketplace, dd-hooks-directory, and dd-mcp-directory.
The deprecated convex/ directory. None of the four migrations deleted Convex on the way out. Each one kept the directory, marked every file with a deprecation banner, and stubbed the exports to throw a readable error. This protects the generated Convex client types that downstream tooling sometimes still references during a release window. Deletion happens in a follow-up PR after one full deploy cycle.
The atomic UPDATE ... RETURNING for any counter. Saves counts, install counts, ratings totals, credit balances. Every app had at least one of these. Every app got the same single-statement pattern. This is the lesson from dd-clipper that paid for itself three more times.
The README cutover note. Same three bullet points each time. Where the database URL goes, what the migration command is, and what is deprecated. It is the smallest part of the playbook and the highest hit rate on developer confusion when it is missing.
If you are running this migration on your own app, those four are the parts you do not have to think about. They are not load-bearing decisions, they are just the right answer.
What differed per app#
Three things were genuinely app-specific. This is where the playbook stopped being a copy-paste and started needing judgement.
Table count and shape. dd-clipper had five tables and a real domain model with credits, usage logs, and clips. The directory trio (skills, hooks, mcp) had four small tables each with the same shape, basically items + saves + installs + ratings. adcraft-ai has seven and a more complex one, including users, generations, canvas elements, and brand kits. The playbook scales to all three sizes, but the work per table is not constant. Domain tables with business logic take longer than directory join tables, and the second migration did not magically make domain logic faster.
Reactivity tolerance. The directory trio did not need useQuery reactivity at all. A user saves a hook, the page revalidates on next nav, nobody notices. dd-clipper needed reactivity for the clip library and we made the call to accept polling. adcraft-ai has a canvas with multiple elements being edited and that is genuinely a place where reactivity matters, so the migration there has to pick a real-time layer up front, not punt. The playbook step says "make the decision," not "the decision is the same." Across four apps the decision was different three times.
File storage. dd-clipper had clip blobs in Convex _storage. The directory trio had no file storage at all, which made their migrations meaningfully smaller. adcraft-ai has generated images and brand kit assets, which is its own project on top of the table migration. The playbook explicitly separates these. If your app has files, expect the migration to be two projects, not one. If it does not, you just got a free speed-up.
The speed numbers, honestly#
Here is the part everyone wants. I am going to be direct because the rounded version is misleading.
dd-clipper took most of a week of evenings. Five PRs, a planning doc, a real war story. Some of that was migration work. A lot of it was figuring out the playbook by hitting walls.
The directory trio shipped tonight in one sitting. Not parallelized in the agent-team sense, but each migration was under an hour of focused work once the playbook was in hand and an agent was running the table-by-table scaffolding. Three apps, one evening, three merged PRs. The PRs are small because the apps are small, and the apps are small because they are directories, but the structural reason it was fast is that none of the seven steps required new thinking.
The honest framing is this. The first app paid for the playbook. The second app validated it. The third and fourth apps were the payoff. If you are looking at a portfolio of similar apps and trying to decide whether to migrate the cheap one first or the expensive one first, do the expensive one first. The playbook you build will pay for itself across everything else.
adcraft-ai is in flight as I write this. Seven tables, real reactivity needs, file storage to plan around. I expect it to land somewhere between dd-clipper and the directory trio in time, probably closer to dd-clipper because the domain logic is real. The flagship site, developers-digest-site, has seventeen tables and is on deck after that. That one is its own multi-PR series.
When this approach is right, and when it is not#
The playbook is right when you have a portfolio of small to medium apps that all share Convex as a dependency, you already run Postgres elsewhere, and the split-stack tax across apps is real. The compounding only happens if you have more than one migration to do.
The playbook is right when your reactivity needs are tractable. Polling works for directories. Polling plus optimistic updates works for most CRUD. If your app genuinely needs collaborative editing or live presence, the migration is still doable, but the reactivity decision in step six is now a real architecture project, not a paragraph.
The playbook is wrong when your app is one app and it works fine on Convex. The first migration cost is real. You only get the speed-up if there is a second app to apply it to. If you have one Convex app and it ships, leave it.
The playbook is wrong when most of your data is files. If your Convex usage is mostly _storage with a thin metadata table on top, the migration is a file storage project with a side of SQL, and the seven steps above are the wrong frame.
If your app is on Convex, working, and you are trying to decide whether any of this applies to you, the tools comparison page has the side-by-side and the rest of the migration notes in this series cover the trade-offs in more detail.
Frequently Asked Questions#
What is the fastest way to figure out table scope?#
Export your Convex data, list the tables you actually have, then walk your codebase callsites for useQuery, useMutation, and any file storage usage. The migration plan is only as good as the inventory.
Should I migrate file storage at the same time as tables?#
Not if you can avoid it. Treat file storage as a separate project: inventory files, decide on the destination (S3, R2, etc.), backfill, and only then delete Convex storage. The playbook works best when file storage is a second pass.
Is drizzle-kit push safe for production?#
It can be, but treat it as a workflow choice. For early stages, pushing schema can be fast. For mature systems, generate and review SQL migrations so schema changes are explicit and code-reviewed.
What replaces Convex reactivity?#
There is no universal replacement. For many CRUD apps, polling and revalidation are enough. If your app needs real-time collaboration, the migration includes picking a real-time layer, not just swapping a database.
Four apps in, the migration is no longer interesting. That is the goal. Boring is the point. The next app is just the playbook again.
Continue Reading#
Get the next deep dive like this in your inbox
One email a week on Convex and the rest of the AI dev stack. Free.
Read next
Neon Postgres in 2026: Review and Setup for AI App Builders
Neon Postgres review and setup for AI app builders - branching, scale-to-zero, the serverless driver, verified pricing, and who should skip it.
9 min read38 Apps in One Day: Migrating an Empire from Replit to Coolify
Migrating 38 apps from Replit to Coolify in one day with parallel Claude Code subagents, gh and neonctl - the recipe and real stats on stubs and false-empties.
8 min readThe Next.js AI App Stack for 2026
Next.js AI app stack for 2026: Next.js 16, Vercel AI SDK, Convex, Clerk and Tailwind - why each piece matters and how they fit together.
12 min readNew here? Start with
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.







