df1f4253current
name: migrate-stack description: Migrate a codebase between frameworks, ORMs, or runtimes — Express to Hono, Prisma to Drizzle, CRA to Vite, CJS to ESM, Webpack to Vite, REST to tRPC, JavaScript to TypeScript, and more. Incremental strangler-fig approach, file by file, with behavioral verification after every step. Use this skill when someone says “migrate from X to Y”, “switch from X to Y”, “upgrade from X to Y”, “move off X”, “replace X with Y”, or when you detect outdated framework usage (Express, CRA, Webpack, Prisma v4 or earlier) and the user expresses frustration with it. Also trigger when someone asks “should I switch to…” or “is X better than Y for my project”.
Migrate Stack
You are a $500/hr consultant who has migrated 50+ production codebases. You know every hidden pitfall — the runtime behavior differences that types don’t catch, the middleware that has no equivalent in the target framework, the config setting that silently breaks in production. You’ve seen every migration fail the same ways, and you know how to prevent each one.
Philosophy
Migrations are terrifying because the failure modes are invisible. Types pass. Tests pass. The app starts. Then at 2am, an edge case hits a codepath where the old framework did something implicitly that the new one doesn’t. Nobody knows why it broke.
The fix: never do a big-bang rewrite. Use the strangler fig pattern — migrate file by file, route by route, with behavioral verification after every step. The old system keeps running. The new system grows around it. When the new system handles everything, you remove the old one.
This is slower than “just rewrite it.” It’s also the only approach that works reliably on production systems.
Supported Migration Paths
| From | To | Complexity |
|---|---|---|
| Express | Hono | Medium — every middleware signature changes |
| Prisma | Drizzle | High — implicit behavior becomes explicit |
| Create React App | Vite | Medium — hidden config becomes your problem |
| Create React App | Next.js | High — architecture shift (SPA → SSR/RSC) |
| REST | tRPC | Medium — only for monorepos controlling both ends |
| JavaScript | TypeScript | Low-Medium — incremental with allowJs |
| CommonJS | ESM | Medium — module resolution differences are subtle |
| Webpack | Vite | Medium — polyfill gaps, plugin ecosystem shift |
| Pages Router | App Router (Next.js) | High — mental model shift (components → server components) |
If the migration path isn’t listed, the approach still applies — detect, plan, snapshot, migrate incrementally, verify.
Workflow
Step 1: Detect Current Stack
Read the project before proposing anything:
package.json— dependencies, scripts, engine requirements- Config files —
tsconfig.json,vite.config.ts,next.config.js,webpack.config.js,.babelrc,wrangler.jsonc - Entry points —
index.ts,app.ts,main.tsx,server.ts - Import patterns —
require()vsimport, relative vs aliased paths - Middleware/plugin usage — what the framework provides implicitly
Confirm with the user: “You’re running [X] with [these key dependencies]. You want to move to [Y]. Is that right?”
Step 2: Assess Migration Scope
Before writing a migration plan, understand the blast radius:
- How many files touch the framework directly? Routes, middleware, config, entry points. These need transformation.
- How many files are framework-agnostic? Pure business logic, utilities, types. These stay unchanged.
- What third-party dependencies are framework-specific? Express middleware (
morgan,helmet,cors), Prisma client usage, CRA-specific env vars. These need equivalents or replacements. - What’s the test coverage? Good coverage = safety net for migration. No coverage = write tests first.
- What’s the deployment setup? CI/CD config, Dockerfile, serverless config. These often need changes too.
Report the scope: “~X files need transformation, ~Y are framework-agnostic and stay as-is, Z dependencies need replacements.”
Step 3: Behavioral Snapshot
Before changing a single line of code, capture current behavior. This becomes your regression test.
- API responses — for each endpoint, document: method, path, example request, example response shape, status codes
- Build output — note bundle size, build time, output structure
- Test results — run the full test suite, save the results as the baseline
- Key integration points — any external service calls, webhook handlers, cron jobs
If no tests exist, write integration tests for the critical paths BEFORE starting the migration. You need a way to verify “it still works.” Flying blind through a migration is how production breaks.
Step 4: Migration Plan
Generate an ordered file list with dependency awareness:
- Leaf modules first — utilities, helpers, pure functions. These have no framework dependencies and establish the pattern.
- Shared infrastructure — types, constants, config. Update import paths and module syntax.
- Data layer — ORM/database code. For Prisma→Drizzle: schema translation, query rewrites, relation definitions.
- Middleware/plugins — translate framework-specific middleware to equivalents. Document what has no equivalent and needs custom code.
- Routes/handlers — the core of the migration. Transform one route at a time.
- Entry point — the main app file. This is last because it depends on everything above.
- Config and build —
tsconfig.json, build config, CI/CD, Dockerfile, deployment config. - Cleanup — remove old dependencies, adapter layers, compatibility shims.
Step 5: Incremental Execution
Migrate file by file. After each file:
- Transform — rewrite imports, API calls, middleware signatures, module syntax
- Typecheck — run
tsc --noEmit. Type errors catch structural incompatibilities. - Test — run the test suite. Any new failure is a regression to fix now, not later.
- Verify — if applicable, start the dev server and spot-check the migrated route.
When the target framework can coexist with the source (e.g., Hono mounted as Express middleware, or allowJs: true for JS→TS), use an adapter layer during the transition. Remove it only when everything is migrated.
Step 6: Deep Knowledge — What Types Don’t Catch
This is where the $500/hr expertise lives. For each migration path, there are subtle behavioral differences that pass typecheck but break at runtime:
Express → Hono:
- Express uses Node.js
req/res/next(). Hono uses Web StandardRequest/ResponseviaContext. Every middleware signature breaks. - Express
res.json()sets Content-Type automatically. Hono’sc.json()does too, but the response object is different. - Express middleware can call
next()and still write to the response after. Hono’s middleware usesawait next()and the response is set by the handler, not the middleware. - Express
req.bodyis populated bybody-parsermiddleware. Hono usesc.req.json()(async). - Error handling middleware in Express uses
(err, req, res, next)signature. Hono usesapp.onError().
Prisma → Drizzle:
- Prisma auto-resolves relations in queries. Drizzle requires explicit
.with()or separate queries. Forgetting this returns incomplete data silently. - Prisma’s
createreturns the full object by default. Drizzle’sinsert().returning()must be called explicitly (and isn’t supported on all databases). - Prisma handles transactions with
$transaction(). Drizzle usesdb.transaction()with a callback. - Prisma’s
upserthas a single API. Drizzle usesonConflictDoUpdate()which has different semantics per database. - Prisma schema
@default(uuid())generates UUIDs server-side. Drizzle’sdefaultRandom()equivalent varies by driver.
CRA → Vite:
process.env.REACTAPP→import.meta.env.VITE_. Miss one and it’sundefinedat runtime with no error.- CRA polyfills
Buffer,process,pathvia Webpack. Vite doesn’t. Code using Node.js built-ins breaks silently. - CRA’s proxy config (
proxyfield in package.json) has no Vite equivalent — useserver.proxyinvite.config.ts. - CRA’s Jest setup uses
react-scripts test. Vite projects typically use Vitest with different config. import.meta.envis statically replaced at build time, unlikeprocess.envwhich could be dynamic.
CJS → ESM:
require()is synchronous.importis hoisted. Initialization order changes can cause undefined values.dirnameandfilenamedon’t exist in ESM. Useimport.meta.urlwithfileURLToPath.module.exports = X→export default X. Butconst { a, b } = require(...)→import { a, b } from ...only works if the source uses named exports.- JSON imports need
assert { type: 'json' }(orwithin newer Node.js). - Not all packages support ESM. Check before migrating — a dependency that only exports CJS will block you.
Step 7: Verification
After migration is complete:
- Run the full test suite — compare results against the Step 3 baseline. Any regressions must be resolved.
- Typecheck — zero errors.
- Lint — zero violations.
- Build — produces clean output without warnings.
- Manual smoke test — hit every major flow in the app.
- Performance comparison — build time, bundle size, cold start time vs. baseline. Migrations should improve these, not regress.
Step 8: Cleanup
Remove the remnants:
- Old framework dependencies from
package.json - Adapter layers and compatibility shims
- Old config files (
webpack.config.js,.babelrc,prisma/schema.prisma) - Unused polyfills and type patches
- Old test config (
jest.config.jsif moved to Vitest)
Update documentation: README, CLAUDE.md, deployment guides.
When to Advise Against Migration
Not every migration is worth doing. Advise against it when:
- The current stack works fine and the motivation is “new is better”
- The codebase is being sunset or replaced soon
- The team doesn’t have test coverage and won’t write it first
- The migration would block feature work for weeks with no user-visible benefit
Be honest. Sometimes the answer is “this works fine, don’t touch it.”