Migrating a community platform without migrating the database
lakebbs went from Vue 2 + Express + Sequelize to Next.js 16 + React 19 + Drizzle ORM — without changing the MySQL 5.7 schema. Why locking the database during a stack rewrite is the boring, correct decision.
- #nextjs
- #drizzle
- #mysql
- #migration
- #engineering-discipline
- #rewrite
lakebbs is a Chinese-language community platform for smaller Canadian cities. It started in 2024 on what was, at the time, the practical stack: Vue 2 + Vite + Pinia on the frontend, Express + Sequelize + MySQL 5.7 on the backend. Two years later that stack was a drag — types were loose, server-side rendering didn’t exist, the AI tooling for refactoring across the codebase was much weaker on Vue 2 than on modern TypeScript / React.
The rewrite that landed in May 2026 moved the entire app to Next.js 16 (App Router) + React 19 + TypeScript + Drizzle ORM + NextAuth v5 + Tailwind 4. The interesting part: the MySQL schema didn’t change. Not a column added, not a column dropped, not a type widened. Same database, same data, same lakebbs MySQL 5.7 instance behind the same nginx reverse proxy.
This post is the case for why I held the database fixed during the rewrite, and what that turned the project into.
The temptation to “fix everything”
The first instinct when scoping a rewrite is to fix every legacy decision at once. The schema is the part that always feels worst because:
- The original ORM (Sequelize, in this case) generated some quirky column types —
TINYINT(1)for booleans,DATETIMEinstead ofTIMESTAMP, etc. - The original migration history has dead tables from features that didn’t ship
- Some join tables are awkwardly named (
user_post_likesinstead oflikes) - Foreign key constraints aren’t applied consistently
The argument for “fix it now, while we’re rewriting anyway” is real. But the argument against is stronger, and it took me two attempted rewrites to actually internalize it.
The trap: a database rewrite is a project, not a step
If you change the schema while rewriting the application, you have:
- A code project — write new components, new ORM models, new routes
- A data project — write migrations that transform existing rows
- A deployment project — coordinate code cutover with data cutover, with rollback paths for both
- A correctness project — verify that the new app on the new schema produces the same outputs as the old app on the old schema, on real data
Each of those projects has its own failure modes. The combined project has the PRODUCT of those failure modes. And — this is the part I underestimated twice — they’re not independent. Bugs in (2) look like bugs in (1). A type mismatch between Drizzle’s expected column shape and the actual schema looks like a frontend bug, until you go three levels deep.
By contrast, if you hold the schema fixed:
- (1) becomes a pure code project — write Drizzle models that match the existing schema, write new routes that produce the same SQL the old routes did
- (2) is empty — there is no data migration
- (3) is a single cutover — point the new app at the existing database
- (4) is checking that the new app produces the same outputs as the old app
The total work is bounded by (1). And (1) is the work I actually want to do anyway.
What this looks like in Drizzle
Drizzle ORM is well-suited to the “match an existing schema” approach because it does NOT require you to start from a migration. You write the schema definitions to match what’s already in the database, and Drizzle just queries against that.
// drizzle/schema.ts — describes what's in MySQL 5.7 already.
import { mysqlTable, int, varchar, text, datetime, tinyint } from 'drizzle-orm/mysql-core';
export const posts = mysqlTable('posts', {
id: int('id').primaryKey().autoincrement(),
cityId: int('city_id').notNull(),
sectionId: int('section_id').notNull(),
authorId: int('user_id').notNull(), // legacy column name preserved
title: varchar('title', { length: 255 }).notNull(),
body: text('body').notNull(),
// Sequelize default: TINYINT(1) for booleans
isPinned: tinyint('is_pinned').default(0).notNull(),
createdAt: datetime('created_at').notNull(),
updatedAt: datetime('updated_at').notNull(),
});
The model has the awkward column names (user_id for the foreign key to users.id) preserved AS-IS in the database, but exposed with cleaner TypeScript names (authorId) at the application layer. The schema legacy stays in the database; the new code is allowed to be clean.
There’s one MySQL 5.7-specific snag worth calling out: Drizzle Kit’s introspect / generate currently assumes MySQL 8+ in some places. I had to patch a couple of internal references to make drizzle-kit pull work against 5.7. That’s a 30-line fix in node_modules/drizzle-kit/... that I’m planning to upstream — small, fixable, not a blocker.
What I gave up by not touching the schema
Honest about the trade-offs:
- The
tinyintbooleans stayed. I wrap them in TypeScript boolean accessors at the model layer, so the application code is fine, but the database itself still has0/1rather thanBOOLEAN(which is just an alias in MySQL anyway, so this is mostly cosmetic). - Some dead tables are still there. Features that didn’t ship in 2024 still have their tables sitting empty. I can drop those later in a separate, isolated migration.
- Foreign keys are still inconsistent. Adding FKs after the fact is a non-trivial cleanup — you have to validate that existing rows would satisfy them, fix the ones that don’t, then add the constraint. I’ll do that separately too.
The pattern: defer all schema cleanup to its own project, with its own rollback plan, after the rewrite is shipped and stable. None of those cleanups need to happen before launch. None of them affect whether the new app works. So they wait.
What I got by NOT touching the schema
Live cutover was a single nginx reload. The old Express app stopped, the new Next.js app started, both pointed at the same MySQL. Zero user-visible downtime beyond a few seconds. No “the site is migrating, please check back later” page. No background data migration running for hours. No “we lost some data, sorry” emails.
The rewrite shipped in weeks instead of months. The 80% of the work that was actually rewriting the application code happened in parallel — frontend rewrite, ORM rewrite, auth rewrite — without coordination on schema state.
And the rollback plan stayed trivial: if the new app turned out broken, I could stop it and restart the old one. Same database, same data. No “rollback also requires rolling back the schema” complication.
When this pattern doesn’t apply
This isn’t universal advice. There ARE rewrites where you have to touch the schema:
- Migrating database engines (e.g. MySQL → Postgres). The schema HAS to change because the type systems are different.
- The schema is the source of the problem you’re rewriting to fix. If the rewrite is happening BECAUSE the schema can’t represent what you need, freezing it defeats the purpose.
- Compliance / privacy migrations. If you’re rewriting to comply with a regulation that requires a different data shape, the schema work is the work.
For everything else — and most rewrites are “everything else” — the schema is a stable boundary. Treat it as one.
The general principle
The principle isn’t really about databases. It’s about isolating risk during a rewrite. Every interface that has to change increases the failure surface multiplicatively, not additively. Holding even one major interface fixed (the schema in this case, but it could be the public API, the auth model, the deployment topology) turns a multi-project rewrite into a single-project rewrite.
The boring version of this advice is “do one thing at a time.” The less boring version is: when you’re tempted to combine two rewrites because they’re already in flight, count the failure modes of each separately, then compare to the failure modes of the combined project. The combined number is almost always more than the sum.
Related
— RELATED READING