launchthat
Portal V2: Learning to Build — Next.js, Drizzle, and the Long Migration
I replaced a WordPress multisite with a custom Next.js app backed by Postgres. Load times dropped from 8 seconds to under 2. But the platform was still fractured across a dozen third-party services, and every schema change was a deployment risk.
This is Part 2 of a four-part series tracing the Portal platform from WordPress to Kubernetes. Part 1 covered the WordPress era. This article covers the rebuild in Next.js — the longest and most humbling phase of the journey.
Starting from zero
The decision to leave WordPress meant starting from almost zero. I knew HTML, CSS, and enough PHP to customize a theme. I did not know TypeScript. I did not know React. I had never written a database migration or designed a schema from scratch. The gap between "WordPress builder" and "full-stack developer" was enormous, and I was staring straight into it.
I spent the first three months learning before writing any production code. React fundamentals. TypeScript's type system. How relational databases actually work — foreign keys, indexes, joins, normalization. Every concept I had skipped by relying on WordPress plugins now demanded real understanding.
The learning was not linear. I would build something, realize it was wrong, tear it down, and rebuild it. The first version of the course data model went through four rewrites before I understood why a lessons table with a moduleId foreign key was better than a JSON blob of nested content stuffed into a single row.
Choosing the stack
I settled on:
- Next.js — React framework with server-side rendering, API routes, and a deployment story on Vercel
- Drizzle ORM — type-safe database layer that generated SQL I could actually read
- NeonDB — serverless Postgres with generous free tier and branching for development
- tRPC — end-to-end type-safe API layer between frontend and backend
- Vercel — deployment platform with preview environments per pull request
The architecture was a traditional monolith deployed to Vercel:
Browser
│
▼
Next.js App (Vercel)
├── React Pages (SSR + CSR)
├── tRPC API Routes
├── Drizzle ORM
│ │
│ ▼
│ NeonDB (Postgres)
│
├── Zapier ──► GoHighLevel CRM
├── Make.com ──► Email marketing
├── Pandadoc ──► Disclaimers
└── Stripe ──► Payments (direct)
Compared to V1's sprawling dependency graph, this was cleaner. The core application logic lived in one codebase. But the integration layer was still fragmented — CRM, email, disclaimers, and several other features still lived in third-party services connected through automation platforms.
The migration
Moving clients from WordPress to the new platform was the most stressful phase of the entire project. I could not do a big-bang migration — the new platform did not have feature parity yet. So I built the LMS features first (courses, modules, lessons, student progress) since that was the core value proposition for most clients.
The migration process for each client:
- Export their WordPress data (courses, students, progress records) using custom WP-CLI scripts
- Transform the data from WordPress's
wp_posts/wp_postmetastructure into the new relational schema - Import into NeonDB with validation checks
- Set up their subdomain on the new platform
- Run both systems in parallel for two weeks while verifying data integrity
- Cut over DNS and decommission their WordPress site
Each migration took about a week of hands-on work. With a handful of clients, that was manageable. The process would not have scaled to fifty clients, but I did not have fifty clients yet — and the few I had were patient because the new platform was dramatically faster.
What worked
Performance. The difference was immediate and obvious. WordPress page loads had averaged 5-8 seconds on the $100/month managed host. The Next.js app on Vercel loaded in 1-2 seconds. Course content rendered instantly with server-side rendering. Navigation between pages was near-instant with client-side routing. Clients noticed on day one. "It's so fast now" was the most common feedback.
Type safety with Drizzle and tRPC. For the first time, the database schema and the API layer shared the same type definitions. If I added a phone field to the contacts table, TypeScript would flag every API route and React component that needed to handle the new field. Coming from WordPress, where a typo in a custom field name could silently return null at runtime, this was transformative.
const courses = pgTable("courses", {
id: serial("id").primaryKey(),
workspaceId: integer("workspace_id").references(() => workspaces.id),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
status: varchar("status", { length: 20 }).default("draft"),
createdAt: timestamp("created_at").defaultNow(),
});
// tRPC router — type errors if schema changes
export const courseRouter = router({
list: protectedProcedure
.input(z.object({ workspaceId: z.number() }))
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(courses)
.where(eq(courses.workspaceId, input.workspaceId));
}),
});
Vercel preview deployments. Every pull request got its own preview URL. I could test changes against a real deployment before merging. After the WordPress era of "push to production and pray," this felt like a safety net I had never known I needed.
The learning itself. Building V2 forced me through the entire web development stack. I learned SQL by writing migrations. I learned React by building real interfaces. I learned TypeScript by fighting the compiler until it stopped complaining. The WordPress ceiling was gone. Every new feature request was now a design problem I could solve, not a plugin I needed to find.
What broke
The frontend/backend split. Drizzle and tRPC helped with type safety, but they did not eliminate the coordination problem. A schema change required updating the Drizzle schema, generating a migration, running it against the database, updating the tRPC router, and updating every React component that consumed the data. Miss one step and the app would break — sometimes silently.
The worst incidents happened when a schema migration succeeded but the API layer served stale types because of a caching issue on Vercel. The frontend showed the correct form fields, the backend accepted the correct data, but the database rejected the insert because the migration had not run against the production database yet. Three different systems, three different deployment timelines, one user-facing error.
The third-party fragmentation. Moving the core LMS to custom code was a massive improvement, but half the platform still lived in external services:
| Feature | Service | Monthly Cost |
|---|---|---|
| CRM / pipelines | GoHighLevel | $97 |
| Disclaimers / contracts | Pandadoc | $35 |
| Email marketing | Mailchimp | $20 |
| Automation glue | Make.com | $29 |
| Automation glue | Zapier | $49 |
| Subtotal (third-party) | $230/mo |
Every time a client signed a disclaimer in Pandadoc, a Zapier automation had to sync that event back to the portal database. Every new CRM contact in GoHighLevel needed a Make.com scenario to create the corresponding record in Postgres. The automations were more reliable than in V1 — I had better error handling now — but they were still external systems I did not control, with their own rate limits, pricing changes, and downtime windows.
Deployment-induced outages. The application was a monolith. One deployable unit on Vercel. A bug in the billing page could crash the course viewer. A memory leak in a background API route could slow down the entire application. I had moved from WordPress's "update a plugin and break everything" to "deploy a commit and break everything." The failure mode was the same — only the technology was different.
AI struggled with the architecture. By this point, AI coding assistants were becoming genuinely useful. Cursor and Copilot could write React components and tRPC routes. But they struggled with the full picture. A simple task like "add a phone field to contacts" required changes across the Drizzle schema, a database migration file, the tRPC router, the contact form component, the contact list component, and the contact detail page. AI would nail two or three of those and miss the rest. The architecture was too spread out for a single prompt to capture.
The cost picture
V2 was cheaper than V1 in hosting costs but still carried significant third-party overhead:
| Category | V1 (WordPress) | V2 (Next.js) |
|---|---|---|
| Hosting / compute | $100 | $20 (Vercel) |
| Database | included | $0 (Neon free tier) |
| Third-party services | $270 | $230 |
| Total | $370/mo | $250/mo |
A $120/month reduction was meaningful on a bootstrapped budget, but the third-party slice was still the majority of the bill. Each of those services represented a feature I had not built yet — and each one was a dependency I could not fully control.
The breaking point
V2 served clients well for over a year. Load times stayed fast. The codebase was maintainable. New features shipped without the terror of the WordPress days. But two forces converged to push me toward V3.
First, the frontend/backend sync problem was getting worse, not better. As the schema grew — more tables, more relationships, more edge cases — the coordination cost of each change grew proportionally. A "simple" feature like adding tags to courses touched six files across three layers. The development velocity I had gained by learning custom development was being eaten by architectural friction.
Second, clients started asking for real-time features. Chat. Live notifications when a student completed a module. Presence indicators showing who was online. Postgres with polling could fake it, but true reactivity required a fundamentally different approach. Bolting WebSocket support onto a Vercel-deployed Next.js monolith was possible but ugly — and it would make the deployment story even more fragile.
I started looking at alternatives. A colleague mentioned Convex — a backend platform where the database, API layer, and real-time subscriptions were a single system. No migrations. No separate deployment step for the backend. Schema changes propagated instantly to every connected client.
I was skeptical. It sounded too good to be true. So I built a prototype.
The prototype took two days. The same feature set would have taken two weeks in the Drizzle/tRPC stack. I was not skeptical anymore.
Next in the series: Portal V3: The Convex Migration — When the Backend Finally Clicked — where the backend and frontend finally spoke the same language, third-party services started getting canceled, and development velocity doubled overnight.
Want to see how this was built?
See the Portal projectWant to see how this was built?
Browse all posts