Blog

Architecting My Own Portfolio: Next.js, Supabase, and the Bugs I Caught Along the Way

A technical walkthrough of building this site's data layer, admin dashboard, social sync, and newsletter system - including a real RLS security check, a dark-mode leak I designed around, and a couple of bugs that only showed up under real testing.

Architecting My Own Portfolio: Next.js, Supabase, and the Bugs I Caught Along the Way

Overview

Most portfolio sites are static pages with a contact form that opens mailto:. This one isn't - it's a full product: a Supabase-backed CMS, a real admin dashboard with authentication, a multi-platform social media aggregator with live API integrations, and a CRM with double opt-in email and automated follow-up reminders. I wanted the site itself to be the strongest evidence that I design systems, not just ship pages.

This post walks through the architecture decisions that mattered, plus a few real bugs I caught along the way - because a "how I built it" post that skips the mistakes isn't very useful.

The data layer: Postgres + RLS as the actual security boundary

Every piece of content on the site - projects, blog posts, seminars, testimonials, social posts, newsletter subscribers - lives in Postgres via Supabase, with Row Level Security doing the real enforcement work rather than the app layer. The admin dashboard writes as my own signed-in Supabase Auth user, not a service-role key, so if there's a bug in a Server Action, RLS is still standing between a mistake and the database - not just "the code that's supposed to check permissions."

I didn't just assume this worked. I wrote a throwaway script that:

  1. Attempted to insert a row as an anonymous client - correctly rejected by RLS.
  2. Signed in as the real admin user and inserted/updated/deleted a row - succeeded.
  3. Confirmed an anonymous client still couldn't read a draft row even after it existed.
Anon insert blocked as expected: YES (new row violates row-level security policy)
Signed in as admin: YES
Authenticated insert: OK
Authenticated update: OK
Anon cannot read draft: YES (correctly hidden)
Authenticated delete (cleanup): OK

That's the difference between "I added RLS policies" and "I verified the RLS policies actually do what I think they do."

A locked decision: Markdown only, no rich-text editor

Every long-form content type on this site - project case studies, blog posts, seminar write-ups - shares one authoring format: Markdown, rendered through a single shared component. No WYSIWYG editor, no per-content-type rendering logic. That's a deliberate, locked decision, not something I'm leaving open to revisit later. One renderer, one editor pattern, one mental model, whether I'm writing a case study or this very post.

A theming problem most people wouldn't think about

The admin dashboard supports light and dark mode. The public site does not - it's light-only, on purpose, since it's a brand presentation surface, not a tool I use daily. That sounds simple until you realize both surfaces render inside the same Next.js app, sharing one document during client-side navigation.

The obvious approach - a theming library that toggles a class on <html> - would leak dark mode into the public site the moment someone navigated from /admin back to /, since the class lives on a shared root element. Instead, the admin theme provider scopes its dark-mode class to a <div> wrapping only the admin subtree, and Tailwind's dark: variant is configured to match any .dark ancestor, not specifically <html>. Small decision, but the kind that's invisible when it's right and very visible (a client's public site suddenly rendering dark) when it's wrong.

Catching a real bug before a client would

When I wired up authentication, I didn't have a browser automation tool available to click through a real login. So I replicated Supabase's own cookie-handling logic in a script - signed in, captured the session cookie the way a real browser would receive it, and fired real HTTP requests at the running server with that cookie attached.

The first run came back with an HTTP 500 on the admin dashboard - even though the page content underneath was rendering correctly. The server logs pointed straight at it: a UI component (a sidebar tooltip) needed a provider I'd never wrapped the app in. The CLI tool that generated that component had printed the exact instruction when I installed it - I'd just missed it in the output. Fixed, re-ran the same verification script, clean result.

The lesson wasn't "read more carefully" (though, sure). It was that a verification script that exercises the real code path - not just "does it typecheck" - will catch things a build success never will.

Security detail: why a confirmation token can never be a return value

The newsletter uses double opt-in: subscribe, get an email with a confirmation link, click it, you're in. Simple enough - until you think about where the confirmation token lives in the code.

My first instinct was to put the "create a pending subscriber and return their confirm token" logic directly in the same file as the public-facing subscribe action. That's a mistake with a specific shape: any file marked as a server actions file has every export automatically become a callable endpoint - including internal helper functions never meant to be called directly. If that token-returning function were reachable that way, anyone could call it with an arbitrary email address, get the token back in the response, and confirm that email's subscription without the owner ever proving they own it. Double opt-in exists specifically to prevent that.

The fix was structural: the function that mints and returns a token lives in a plain internal module, never in a server actions file. Only a thin, safe wrapper - the one visitors actually call, which never echoes the token back - is exposed publicly.

Respecting real platform constraints

The social media page pulls live data from GitHub, YouTube, Facebook, and Instagram, syncing on a daily cron job (Vercel's free tier caps you at once a day, not the every-few-hours I originally planned - so "Run Sync Now" exists in the admin dashboard for anything more immediate). Two things forced real design changes:

  • Facebook and Instagram access tokens expire roughly every 60 days. Storing them as environment variables would mean "edit a config file and redeploy" every two months. Instead, they live in an admin-editable settings table - renewing one is a paste-and-save, not a deployment.
  • The GitHub integration started as an activity feed (recent pushes, PRs, releases) before I realized that only surfaces repos with very recent activity - most of my actual work wasn't showing up. It's now a full repository showcase instead, sorted by most recently updated. Sometimes the right fix isn't debugging the code you wrote - it's recognizing you built the wrong thing.

What this actually demonstrates

None of this is complicated in isolation. What I think it demonstrates is the habit of treating "it builds" and "it works" as different claims, designing for the failure mode before it happens (a leaked theme, an exposed token) rather than patching it after, and being honest in the process about the two or three things that genuinely went wrong along the way.

That's the same habit I bring to client work - and now it's the site itself making that case, not just a paragraph claiming it.