Rebuilding My Portfolio: Moving From React + Express + MongoDB to Next.js, TypeScript, and Postgres
For a couple of years, this site ran on a pretty standard combo: a React app built with Vite talking to an Express API, backed by MongoDB. It worked fine. But it was also two separate codebases, two separate deployments, and a growing pile of small annoyances that made me want to start fresh.
So I rebuilt the whole thing as a single Next.js 16 application, written in TypeScript, running on Cloudflare Workers, talking to a Postgres database instead of MongoDB. This post walks through why I made those choices, how the migration actually went, and — maybe more usefully — the specific bugs I ran into along the way and what they taught me. If you're a beginner thinking about doing a similar rewrite, I've tried to explain the jargon as I go rather than assume you already know it.
Why rewrite a site that already worked?
A few things kept nagging at me about the old setup:
- Two codebases, two deployments. The frontend and backend lived in separate folders with separate
package.jsonfiles, separate hosting, and separate environment variables to keep in sync. Every feature touched both sides. - No type safety. Both the frontend and backend were plain JavaScript. It's easy to underestimate how many bugs a type checker catches until you don't have one anymore — a typo in a field name, a function that sometimes returns
undefined, that kind of thing. - MongoDB didn't fit the data. My data (blog posts, comments, projects, one admin user) is genuinely relational and small. I was using MongoDB mostly because it was the "default" choice when I first built the site, not because documents were actually the right shape for what I was storing.
None of these were emergencies. But they were the kind of thing that made every small feature take longer than it should have, so I picked a stack that fixed all three at once.
Picking the new stack
Here's what I landed on, and why:
- Next.js 16 (App Router) instead of React + Vite + Express. Next.js lets you write your frontend and your API routes in the same project, with the same language, the same types, and one deployment. Fewer moving parts.
- TypeScript instead of JavaScript. Once you've felt a red squiggly line save you from shipping a bug, it's hard to go back.
- Postgres instead of MongoDB, via Neon (a Postgres host built for serverless environments) and Drizzle ORM (a lightweight tool for writing type-safe database queries). My data has clear relationships — a blog post has comments, a comment belongs to a post — which is exactly what a relational database is good at.
- Cloudflare Workers instead of a traditional Node server, using the OpenNext adapter, which compiles a Next.js app into something Workers can run. Workers are fast to cold-start and cheap to run for a low-traffic personal site.
- Cloudflare R2 instead of storing images in MongoDB. R2 is Cloudflare's S3-compatible object storage — built for exactly this job, unlike a database.
If you're doing your own rewrite: don't feel like you need to change everything at once. I changed the framework, the language, and the database all together, which made the migration bigger than it needed to be. It worked out, but doing it in stages (say, add TypeScript first, then swap the database) would have been less risky.
Setting up the new project
A Next.js App Router project organizes itself around a app/ folder where the folder structure is your routing. A file at app/blog/page.tsx becomes the page at /blog. A file at app/api/blog/route.ts becomes an API endpoint at /api/blog. Coming from Express, where you wire up routes by hand (app.get("/blog", ...)), this took a little getting used to — but it also means your API and your pages live right next to each other in the same project, instead of in a whole separate repository.
Migrating the database
This was the part I was most nervous about. MongoDB stores flexible, schema-less JSON-like documents. Postgres wants you to define your tables and columns up front. In practice, this turned out to be a good exercise — it forced me to actually think about what my data looked like instead of letting it grow organically.
Here's roughly what a blog post table looks like with Drizzle (this is simplified, but shows the idea):
export const blogs = pgTable("blogs", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
markdown: text("markdown").notNull(),
sanitizedHtml: text("sanitized_html").notNull(),
imageKey: text("image_key").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const blogComments = pgTable("blog_comments", {
id: serial("id").primaryKey(),
blogId: integer("blog_id").notNull().references(() => blogs.id, { onDelete: "cascade" }),
name: text("name").notNull().default("Anonymous"),
text: text("text").notNull(),
});
Two things stood out compared to MongoDB:
- Relationships are explicit.
blogComments.blogIdreferencesblogs.id, andonDelete: "cascade"means that deleting a blog post automatically deletes its comments. In MongoDB, comments were just an array embedded inside the blog document — simpler in one way, but it meant every comment fetch pulled the whole post along with it. - You can't cheat the schema. In MongoDB, if I forgot to set a field on save, it just... wasn't there, and I'd find out at runtime when something tried to read it. In Postgres, a
NOT NULLcolumn that's missing a value fails the insert immediately, which is annoying in the moment but catches real bugs before they ship.
One quirk of running Postgres from Cloudflare Workers specifically: Workers can't hold a long-lived TCP connection open the way a normal Node server can, so I connect through Hyperdrive, Cloudflare's connection-pooling layer, rather than talking to Neon directly.
Rebuilding authentication
The old app rolled its own auth: an Express route that checked a password with bcrypt, signed a JWT by hand, and set it as a cookie. It worked, but it was code I had to trust myself to get right — session expiry, cookie flags, all of it.
For the rewrite I used next-auth (now called Auth.js) with its "Credentials" provider, which still does the bcrypt-password-check-then-issue-a-JWT dance under the hood, but handles the cookie security details for you.
The part that actually tripped me up wasn't next-auth itself — it was where the auth check runs. Next.js 16 has a file called proxy.ts (in older versions it was middleware.ts) that can intercept every request before it reaches a page, which is the natural place to say "if you're not logged in, redirect to /login." I wired this up and it worked great for pages like /admin/dashboard.
What I didn't realize at first: that middleware only covers pages, not API routes. I had several API endpoints — creating a blog post, deleting a project — that were sitting there completely unauthenticated, because I'd assumed the same middleware protected everything under /admin/*, API routes included. It didn't. Anyone who found the URL could have called them directly.
The fix was to write a small reusable helper that each API route calls at the top of its handler:
export async function requireSession(req: NextRequest) {
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
if (!token) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
return null; // null means "you're allowed through"
}
Lesson learned: page-level route protection and API-level route protection are two separate concerns, even when they live in the same framework. Don't assume one covers the other — check explicitly.
Handling image uploads
The old backend used multer to accept an uploaded file and stored the raw image bytes directly inside the MongoDB document. It worked, but a database really isn't the right place to store binary blobs — every query that touched that document was dragging image bytes along for the ride.
The new version uploads images straight to Cloudflare R2 and stores only the object key (basically, the filename/path inside the bucket) in Postgres:
export async function storeImageFromFormData(file: File, keyPrefix: string) {
const buffer = await file.arrayBuffer();
const imageKey = `${keyPrefix}/${crypto.randomUUID()}`;
await uploadImage(imageKey, buffer, file.type);
return { imageKey };
}
This is also where I found the funniest bug in the whole migration. The old code had a fallback: if you created a blog post without uploading a cover image, it would fetch a placeholder image from an external URL and use that instead. At some point — long before this rewrite — that external URL started returning an error page instead of an image, and because the code never checked whether the fetch actually succeeded, it had been silently uploading that error page as the "default image" for who knows how many posts. Nobody noticed because nothing crashed; it just quietly did the wrong thing. I replaced the external fetch with a small image bundled directly into the app, so there's no external dependency left to go stale again.
Lesson learned: any time your code has a fallback path, make sure it actually checks for failure. A fallback that can itself fail silently is worse than no fallback at all.
Deploying to Cloudflare Workers
This is the step that doesn't have a MongoDB/MERN-stack equivalent, because it's specific to hosting Next.js on Workers rather than a traditional Node server. The @opennextjs/cloudflare tool takes a normal Next.js build and repackages it into something Workers can run, and a config file (wrangler.jsonc) declares which Cloudflare resources the app needs access to — the Hyperdrive database connection, the R2 bucket for images, and so on.
The two bugs that bit me here were both variations on the same theme: things that work locally can be missing in production, silently.
Bug 1 — the missing secret. Locally, secrets like the one next-auth uses to sign session tokens live in a .dev.vars file that only affects my machine. Production secrets have to be set separately through Cloudflare's dashboard (or CLI). I'd built and tested the whole login flow locally, deployed, and then — when I actually tried logging in on the live site — got a cryptic "There is a problem with the server configuration" error. It turned out the signing secret had simply never been set in production. Locally, everything looked fine because the local file was quietly filling in for it.
Bug 2 — the missing database table. I added a new database table (for rate-limiting login attempts — more on that below) and ran the migration command against my local database to test it. It worked. I deployed the code. Production immediately started failing every single login attempt, because I'd forgotten that "ran the migration locally" and "ran the migration against the real production database" are two completely different commands, and I'd only done the first one.
Lesson learned: local success proves nothing about production. Config, secrets, and database schema all need to be verified against the real production environment, not assumed to match because the code is identical.
Rate limiting, the Workers way
The old Express app used express-rate-limit to block repeated failed login attempts, which — like most rate limiters — keeps its counters in a plain in-memory object. That works fine on a normal server that stays running between requests.
It doesn't work on Cloudflare Workers, where each request can be handled by a fresh, isolated instance of your code with no memory of the last one. An in-memory counter would just reset itself constantly and never actually block anything.
The fix was to store attempt counts in Postgres instead — a table that just logs (who, when) for each attempt, checked and cleaned up on every login:
export async function isRateLimited(key: string) {
const windowStart = new Date(Date.now() - 15 * 60 * 1000);
const attempts = await db
.select()
.from(rateLimitAttempts)
.where(and(eq(rateLimitAttempts.key, key), gt(rateLimitAttempts.createdAt, windowStart)));
return attempts.length >= 5;
}
Lesson learned: a technique that's "obviously correct" on a traditional server (like an in-memory cache) can be silently wrong in a serverless environment. Always ask what your new runtime actually guarantees before reusing old patterns.
The hydration bug that took the longest to track down
This one's worth explaining even if you've never heard the term before. Next.js renders your page's HTML on the server first (so it loads fast and works with JavaScript disabled), then your React code runs in the browser and "hydrates" that HTML — attaching all the interactivity to what's already there. For this to work smoothly, the server-rendered HTML and the first thing React renders in the browser need to match exactly.
My site supports English and French, and detects which one to use by checking the browser's localStorage. The bug: that check was happening too early — during the very first render in the browser, before hydration had finished comparing against the server's HTML. The server had rendered the page in English (it has no way to know your localStorage preference), but if your saved preference was French, the browser's first render could already say French, and React would notice the mismatch and complain.
The fix was to always render in English on both the server and the client's first paint, and only switch languages in a follow-up step after hydration was already complete:
useEffect(() => {
const savedLanguage = localStorage.getItem("language");
if (savedLanguage && savedLanguage !== i18n.language) {
i18n.changeLanguage(savedLanguage);
}
}, []); // runs after the page has already hydrated
Lesson learned: anything that depends on browser-only data (localStorage, window, screen size) has to wait until after hydration to run, or the server and client will disagree about what to render.
A CSS bug that only showed up in one specific layout
Not every bug is exotic. One of my login/register forms started rendering at a tiny fraction of the page's width — maybe 250 pixels wide on a 1920-pixel screen — with a submit button so small its own label got clipped.
The cause: the old app's <body> element used normal, default CSS layout. The new app's <body> uses display: flex; flex-direction: column, added to make the footer stick to the bottom of short pages. That single change had a side effect I didn't anticipate — an element with no explicit width, sitting directly inside a flex container, shrinks to fit its content instead of filling the available space the way it would in normal layout. My form's container had never needed an explicit width before, because it had never been a flex item before.
The fix was a one-line CSS change — adding width: 100% to the container class — but finding why a form was suddenly the wrong size took much longer than fixing it once I understood the cause.
Lesson learned: a layout change that seems purely cosmetic (like "make the footer stick to the bottom") can quietly change how every child element on the page sizes itself. When something's layout breaks for no obvious reason, look at what changed further up the tree, not just at the broken element itself.
Wrapping up
Rewriting a working site is always a bit of a gamble — you're trading a known set of problems for an unknown set of new ones. Looking back, I think it was worth it: one codebase instead of two, real type safety, a database that actually fits the data, and a deployment that costs very little to run. But almost every interesting bug I hit along the way came from the same root cause, just wearing different clothes: an assumption that held true in the old environment (or locally) quietly stopped being true in the new one. Middleware that covered "everything," an in-memory rate limiter, a <body> that wasn't a flex container, a secret that existed locally — none of these were exotic mistakes. They were just old assumptions that didn't get re-checked.
If you're planning your own migration, my honest advice is: budget real time for exactly this kind of bug. The migration itself — moving the code over — is often the easy part. It's re-verifying every assumption your old code quietly relied on that takes the time.
