Skip to content
All Projects

The SMM Place

Production Migration — Express to Next.js

Next.jsTypeScriptFirebaseNode.jsGSAPExpressCloudinaryRBAC

Overview

The SMM Place started as a website I built myself in 2023 — Node.js and Express serving static HTML, Firestore queried directly from the browser, vanilla JavaScript handling interactivity. It was the right stack for what the project needed to be at the time, and it shipped fast. Two years later, the agency brought me back as a contractor to migrate that same site to Next.js and TypeScript. Which meant the first real decision on this project was how to responsibly take apart something I'd built myself, on a site that was live and serving real client traffic for the entire migration.

The fix wasn't a rewrite. Rewrites of working systems tend to take longer than estimated and break things nobody asked to have touched. The approach was the Strangler Fig pattern — run the new Next.js app alongside the old Express server, migrate one route at a time, and only remove the legacy system once the replacement has proven itself in production. Five phases, roughly five weeks, zero deployment windows, zero visible downtime.

What follows isn't really one migration story. It's three: getting off Express without breaking anything, fixing the data integrity problems the migration surfaced once it was actually running, and a full design system rebuild once the foundation was stable enough to justify one.

The Problem

Coming back to a codebase two years after building it gives you a different read on it than the one you had while writing it. Blog posts — the actual content the agency exists to publish — were invisible to Google. Posts rendered entirely client-side through the Firebase JavaScript SDK: the browser would load the page, then separately fetch the post content from Firestore, then render it. Googlebot doesn't wait around for that second fetch. It crawled an empty shell, indexed nothing, and every article the agency had ever published was effectively absent from search.

Security was worse. A /upload route handled image uploads to Cloudinary with no authentication, no role check, and no server-side validation of what was actually being sent. Any HTTP client that knew the URL could upload arbitrary files into the agency's media library. Nobody had exploited it yet, but "nobody has found it" and "it's secure" are different claims, and only one of them survives contact with someone who goes looking.

Warning

Anyone who found this endpoint could upload arbitrary files to the agency's Cloudinary account, unauthenticated, with no audit trail of who did it or when. The fix described in Technical Approach replaces it with a session and role check before a single byte gets accepted — but until that shipped, the gap was live in production, not theoretical.

Underneath both of those sat something less dramatic and more corrosive: the entire codebase was vanilla JavaScript, with no enforced shape for what a blog post object should contain, what fields a Firestore document was supposed to carry, or what a server response actually looked like. That knowledge lived in memory and convention — fine for the person who wrote it, and a liability for whoever extends it next, including that same person once enough time has passed to forget why a given decision was made.

None of this was unusual for a project built to ship fast in 2023. It's the kind of debt that's invisible while a codebase is small and compounds every time something new gets layered on top of it without anyone going back to pay it down. By the time the agency brought me back in 2026, shipping fast and shipping correctly had stopped pointing in the same direction.

Technical Approach

The instinct when you're handed a codebase like this is to rewrite it. Start fresh, do it right this time. That instinct is almost always wrong on a system that's live and earning the agency money while you work on it. The approach here was the Strangler Fig pattern instead — named for the vine that grows around a host tree and gradually replaces it. Next.js ran alongside the existing Express server from day one, handling any route it recognized; anything it didn't know about got silently proxied through to the legacy app:

// next.config.ts
async rewrites() {
  return {
    fallback: [
      {
        source: '/:path*',
        destination: `${process.env.LEGACY_APP_URL}/:path*`,
      },
    ],
  };
}

fallback rewrites only fire when no Next.js route matches. Creating app/about/page.tsx meant /about traffic started hitting Next.js immediately, with zero configuration change required, while everything else kept transparently hitting Express. The first deliverable was deliberately anticlimactic: a Next.js shell in production serving exactly nothing new, just to confirm the proxy preserved cookies and headers correctly before any real migration work started.

Public, static-leaning pages went first — Home, About, Services, Contact — since they carried no auth complexity and the highest SEO upside. The legacy CSS got copied wholesale into globals.css rather than rewritten into Tailwind utilities at this stage. Parity over improvement was the explicit rule: refactoring CSS into something cleaner is real work, and doing it mid-migration is scope creep that kills timelines. GSAP's scroll animations got ported the same way, loaded via CDN inside useEffect, not ideal long-term but exact in preserving the existing visual behavior.

The blog engine was the phase that actually mattered, since blog posts were the content Google couldn't see in the first place. Rendering moved server-side using firebase-admin instead of the client SDK, with both the listing and detail pages using export const revalidate = 60 — statically generated, regenerated in the background whenever content is older than a minute. Two bugs surfaced here that are worth naming specifically, because both are the kind that look obvious only after you've found them. The first: initializing Firebase Admin at module top-level meant the build crashed the instant environment variables weren't present, even on routes that didn't touch Firestore at all — fixed by deferring initialization until the first function that actually needs it runs. The second very nearly redirected the homepage into a 404. The legacy site's URLs were flat (/post-title-slug); the new structure needed a /blog/ prefix, which meant a catch-all redirect:

// Broken: .* matches an empty string, sending / to /blog/
{ source: '/:slug(.*)', destination: '/blog/:slug', permanent: true }
 
// Fixed: (?!$) rejects the empty match, .+ requires at least one character
{ source: '/:slug((?!$|about|services|contact|blog|...).+)', destination: '/blog/:slug', permanent: true }

Test redirects against the root path specifically. It's the one edge case that's easy to never think to check, and the one that takes the whole site down if you get it wrong.

Auth came last, and it directly replaced the open /upload endpoint described earlier. The new flow issues a Firebase Session Cookie on sign-in — httpOnly, secure, five-day duration — verified by Next.js Middleware on every request to a protected route. httpOnly matters specifically because it's not readable by JavaScript at all, which closes off an entire class of token-theft attack that storing a JWT in localStorage leaves open. Roles live in Firebase Custom Claims, attached server-side to the user's token, checked by Middleware before any write operation runs. The upload route that anyone could previously hit anonymously now requires a valid session and a role claim of admin or editor before it streams a single byte to Cloudinary.

With auth and the blog engine both stable in production, Phase 5 was the satisfying part: drop the fallback rewrite, decouple from Express entirely, and retire it. The legacy directory stayed in the repo as a historical reference. Nothing in the running application depends on it anymore.

Key Decisions

Technical Approach covered how the migration itself happened. This section covers what got found and fixed once it was actually running in production — plus a separate piece of work that came later, once the migration had proven stable enough to justify rebuilding the design system on top of it.

Stable IDs, not display names

One editor logged into the new dashboard after the migration and saw zero posts. Not a partial list — zero, in a system he'd been writing for months. Nothing was throwing an error. The session was valid, the role claim was present, the posts existed in Firestore, verifiable directly in the console. The dashboard simply couldn't find them.

The legacy schema stored a post's author as whatever display name the writer's Google account happened to have at the time the post was published — "Ayomide Kay" on one post, "The Web Dev" on another, sometimes both for the same person, because Google lets you rename your account at any point and nothing in the system treated that as consequential. The dashboard's filter compared the logged-in user's current display name against that stored string. The moment a display name changed, the filter started comparing against a string that no longer existed anywhere in the data.

That's the general failure mode of any mutable, user-controlled field doubling as an identifier. Firebase UIDs don't change. Display names do, by design, whenever the user feels like it. The fix had two parts: backfill a stable authorId onto every existing document, then change the query to use it.

The backfill needed three properties to be safe against a live collection: idempotent, so re-running it after a partial failure wouldn't double-process anything; deterministic on ambiguous matches, so a colliding display name always resolved to the more trustworthy identifier; and zero silent failures, with every unmatched document reported by ID for manual review instead of quietly left behind.

// Priority ordering: Display Name < Email Prefix < Email < UID
// Each pass overwrites the last — later passes are higher-confidence,
// so a colliding key just gets reassigned to the more trustworthy match.
const lookup = {};
 
authorizedUsers.forEach((user) => {
  if (user.displayName) lookup[user.displayName.toLowerCase()] = user.uid;
});
authorizedUsers.forEach((user) => {
  if (user.email) lookup[user.email.split('@')[0].toLowerCase()] = user.uid;
});
authorizedUsers.forEach((user) => {
  if (user.email) lookup[user.email.toLowerCase()] = user.uid;
});
authorizedUsers.forEach((user) => {
  lookup[user.uid.toLowerCase()] = user.uid;
});
 
for (const doc of snapshot.docs) {
  const data = doc.data();
  if (data.authorId) continue; // already migrated — idempotency
 
  const matchedUid = lookup[data.author?.toLowerCase()];
  if (matchedUid) {
    await doc.ref.update({ authorId: matchedUid });
  } else {
    unmatched.push({ author: data.author, id: doc.id }); // flagged, not dropped
  }
}

The priority ordering isn't enforced anywhere with conditional logic — it falls out naturally from the order the four passes run in, since each pass overwrites whatever the previous one wrote for the same key. Run against the live collection, it resolved every document cleanly on the first pass, and a second run confirmed idempotency by updating nothing at all.

Backfilling existing documents only solves half of it. Until every document carries authorId, filtering by it alone would silently drop anything not yet migrated from an editor's dashboard — a quieter version of the same bug. The transition-safe version runs both queries in parallel and merges the results:

const [authorIdSnapshot, nameSnapshot] = await Promise.all([
  blogCollection
    .where('authorId', '==', uid)
    .orderBy('publishedAt', 'desc')
    .get(),
  blogCollection
    .where('author', 'in', nameVariants)
    .orderBy('publishedAt', 'desc')
    .get(),
]);
 
const docMap = new Map<string, FirebaseFirestore.DocumentSnapshot>();
authorIdSnapshot.docs.forEach((doc) => docMap.set(doc.id, doc));
nameSnapshot.docs.forEach((doc) => docMap.set(doc.id, doc));
 
const mergedDocs = Array.from(docMap.values());

A Map keyed by document ID makes the dedup trivial — a post matching both queries only ever lands in the result once. This dual-query path only runs for non-admin users, too; an admin's dashboard issues one plain, unfiltered query, since there's no per-author reconciliation needed in the first place. Once every document was confirmed backfilled, the name-variant query became safe to drop, cutting that read path back down to one query instead of two.

Auto-save shouldn't change what a document is

A second bug surfaced in the same review, smaller in scope but worse for being quiet. The original auto-save spec was simple: every auto-save writes the document as a draft. In isolation, that sounds like the conservative choice. Followed through to its actual consequence, it meant auto-save on an already-published post would silently unpublish it — no explicit action from the editor, no warning anything had changed.

The fix is a one-line distinction that's easy to state and easy to miss while writing the original spec: auto-save preserves whatever status the document already has. Only an explicit, deliberate action — clicking publish, clicking unpublish — changes what the document actually is. Saving the current state of something and changing which category it belongs to are different operations, and the bug only existed because the original spec quietly treated them as the same one.

A cascade bug, and the token system that came out of it

Eight phases into a separate design system migration — same project, well after the Strangler Fig work had shipped and proven stable — Tailwind utility classes started getting silently ignored. A class would be present in the DOM, the computed styles panel would show the correct property, and some other rule would still win. Adding !important made the symptom disappear without explaining why it had happened, which is the least satisfying way to fix anything.

The cause is specific to Tailwind v4: it organizes its own output into native CSS cascade layers, and any rule written outside an explicit @layer block in your own stylesheet sits in what the CSS spec treats as an implicit layer above all of them — meaning it wins the cascade unconditionally, regardless of specificity or source order. A handful of leftover bare rules in globals.css were silently beating every Tailwind utility targeting the same elements. Tailwind v3 never had this problem, because v3 doesn't use cascade layers at all.

/* Before — sits outside any @layer, wins the cascade unconditionally */
a {
  text-decoration: none;
  color: var(--deep-black);
}
 
/* After — inside @layer base, Tailwind utilities can now override it */
@layer base {
  a {
    text-decoration: none;
    color: var(--deep-black);
  }
}

Auditing every rule in globals.css for layer membership surfaced a bigger problem underneath the first one: the file had accumulated hundreds of lines of component-specific CSS over time, selectors that had nothing to do with being global. That's the common failure mode in any project that starts without an explicit CSS architecture — the global stylesheet becomes wherever styles go when nobody's decided where they actually belong.

Lesson

The actual fix wasn't just wrapping stray rules in @layer base. It was a two-tier token system — fixed brand tokens that never change, and semantic tokens that components actually consume and that get redefined per theme. Switching from light to dark becomes a variable override, not a component change.

@theme inline {
  /* Brand tokens — fixed identity, never theme-dependent */
  --color-brand-purple: #8e5faf;
}
 
:root {
  --accent: #8e5faf;
  --surface: #f7f6fb;
}
.dark {
  --accent: #b07fd6;
  --surface: #0f0d14;
}

By the end, globals.css held exactly two things: this token system, and one consolidated @layer base. Everything else moved into the component file it actually belonged to. A smaller bug from the same project is worth a quick mention for the same reason the CSV file extension story mattered in PocketFlow — it looked like a code problem and wasn't. The contact form's server action, posting to a third-party form API, worked locally and failed silently in production. The cause was Cloudflare blocking the server-to-server POST behind the scenes, not the code calling it. The fix was moving the submission to a client-side fetch instead — a reminder that a server action isn't always the right abstraction for a third-party service sitting behind someone else's WAF.

Outcome

The migration ran five weeks, in five phases, with zero deployment windows and zero downtime visible to anyone using the site. The actual target from day one was simpler than "ship something better" — replace the system without anyone noticing it happened while it was happening. Lighthouse confirmed the part that mattered most going in: SEO moved from a score near zero, back when blog posts were invisible to Google, to 97 once they were server-rendered. Performance moved from 74 to 88 alongside it.

The legacy version is still online at the-smm-place.onrender.com, kept running on purpose instead of torn down. Open it next to the-smm-place.vercel.app and the before-and-after stops being something I'm telling you about and becomes something you can click through yourself.

The authorId backfill ran clean against the live collection — 9 documents updated, zero unmatched, confirmed idempotent on a second run that updated nothing at all. The editor who'd lost his entire post history to a renamed Google account got it back without anyone touching a document by hand.

Fixing the migration's bugs wasn't the same problem as making the system one a non-technical team could actually run day to day. Before this, adding a new editor meant someone running a Node.js script from a terminal — a real bottleneck for an agency where the person managing content access isn't the person who can read a stack trace. That became an actual User Management interface instead: invite by email, assign a role, revoke it instantly. Revocation specifically means stripping the custom claim and invalidating the refresh token in the same action, rather than waiting out a session cookie's five-day expiry — access ends when someone clicks revoke, not five days after.

What stayed with me from this one wasn't really a technical lesson. Coming back to code you wrote yourself two years earlier, with a client depending on it staying online while you take it apart, is a different kind of pressure than building something new from a blank repo. Most of the discipline in this migration — the Strangler Fig pattern, reading the legacy system instead of rewriting it on instinct, shipping a hollow shell before touching anything real — existed to manage that pressure specifically: protect what already works while you replace it, instead of trusting that a confident rewrite will turn out fine.