Rebuilding This Site in Public: 15 PRs, 10 Days, One Honest Portfolio
This post is about the site you're reading.
I started this rebuild on April 20, 2026 with an honest problem: my portfolio looked sharp on the surface and was almost entirely fabrication underneath. v0 had given me a polished Next.js template with fake testimonials, made-up certification IDs, lorem-ipsum project case studies, and a contact form whose "submit" button just called setTimeout for a second to fake a successful send. The visual design was fine. The substance was zero.
I had a job hunt running. The site couldn't ship like that.
Two weeks later, after 15 pull requests and a lot of evenings, the same domain (isaacvidal.dev) renders a portfolio I'd actually defend in an interview. The code is on GitHub. The CI is green. The contact form delivers real email. Every cert ID resolves to a verifiable Coursera or Credly URL. Every blog post — including this one — is a real .mdx file in content/blog/, rendered through a real MDX pipeline with syntax-highlighted code blocks.
Here's the honest log of how that happened.
What I started with
A v0 export. Specifically:
- Next.js 16 + Tailwind v4 + shadcn/ui scaffold (fine)
- A
Herocomponent, aBentoHerocomponent, a Simple/Stylish view toggle (cool) - 22 sections in the home page, most of them carrying invented content
- Hardcoded blog posts as TypeScript objects in
lib/blog-data.ts, each containing fluent-but-fictional 800-word essays I had not written - Six "project case studies" with real names + completely templated content
- A "Process" section with the 4 steps Discovery → Strategy → Design → Development and copy lifted directly from a designer template
- A "Pricing" page selling consulting packages I do not sell ($3,000 architecture reviews, etc.)
- An FAQ in consulting voice ("How long is your typical engagement?")
- Contact form: client-only
setTimeout, no backend - A
<ViewTransition>import from React 19 that didn't actually exist on the stable channel and was crashing every page
That last one — the broken import — was the immediate fire. Page wouldn't even render. Fixing it was the very first commit.
Once the page rendered, the depth of the v0-isms became visible. The choice was clear: rip out the fabrication, ship real substance, lean on the v0 visual scaffold where it actually held up.
The arc, by phase
Each PR fits a theme. I'll keep this section dense — the interesting commentary lives in the next section.
PR #1 (legacy) — original v0 import, baseline.
Phase 1 — Real data truth pass (PR #2) Replaced the fabricated content layer:
- Real
Timelinefrom my actual employment history (CAF → SmartBot → Supreme Court of Panama → Telered) - Real
Certificationswith placeholder IDs I'd later replace with the actual Coursera/Credly identifiers - Real
Projectsdata based on systems I've genuinely shipped - Hero stats fixed: "8+ years" → "Since 2018" (truthful framing, professional career start)
- Tech stack chips replaced (the original list included Go and Kubernetes — neither of which I run in production)
Phase 2 — Copy polish (PR #3) Killed the consulting voice everywhere:
- Services rewritten in architect voice, with one swap: dropped Data Engineering in favor of Full-Stack Modernization (matches the actual work)
- Process: designer's
Discovery → Strategy → Design → DevelopmentbecameDiscover → Design → Ship → Operate - FAQ reframed from "engagement length" to recruiter-relevant questions (open roles, stack, relocation, side projects)
Phase 3 — Improvements batch 1 (PR #4) Four post-launch quality features in one PR:
- New
/usespage (engineer staple — hardware, editor, stack, productivity, currently learning) - Cmd+K command palette extensions: copy email, download resume, toggle view mode — all with Sonner toast feedback
- Auto-generated OG and Twitter cards (typographic only — Satori doesn't reliably ship local PNGs at build time)
- Speculation Rules API for instant page transitions on hover
Phase 4A — Contact backend, RSS, theme/CLS polish (PR #5)
- Real contact form via Resend, with Zod validation, honeypot anti-spam, in-memory rate-limit (3 requests / 10 min per IP)
- RSS feed at
/rss.xml - Light-mode WCAG AA contrast fix (
muted-foreground0.45 → 0.38) - Sonner theme bug — was hardcoded to dark, broke light mode toasts
- CipherText layout shift fix — random glyphs of varying widths were causing the bento hero to resize 1-3 times during animation. Fixed with a per-character ghost layer (each real character reserves an
inline-blockof its actual width; the cipher glyph is absolutely positioned over it). Verified with Playwright:uniqueHeights: 1, uniqueWidths: 1.
Phase 4B — MDX migration (PR #6)
Replaced the hardcoded lib/blog-data.ts with a real content pipeline:
content/blog/*.mdxfiles- Server-side rendered MDX via
next-mdx-remote/rsc— no client bundle cost - Reading time computed automatically from
reading-time - Anchor links on headings via
rehype-slug+rehype-autolink-headings - Dual-theme (
github-light/github-dark) syntax highlighting viarehype-pretty-code - Wrote 4 real-ish blog posts grounded in actual work — not lorem ipsum
PR #7 — Cipher fix v2 (the bug Isaac caught) The Phase 4A fix wasn't enough. Caught + redone properly.
Phase 4C — Pre-launch cleanup (PR #8)
- Deleted dead
/pricing,/privacy,/termsroutes - Replaced footer with a single privacy disclosure line
- Real cert IDs, real Coursera URLs, real Credly badges
Phase 5 — Going live (no PR; Cloudflare + Vercel + Resend)
Bought isaacvidal.dev at Cloudflare for $12.20. Caelix.org placeholder for the eventual company brand. Wired Vercel → Cloudflare DNS, Resend → Cloudflare DNS for the contact form. Site live at the real domain.
Phase 6A — Maintenance switch + production README (PR #9)
Env-var-toggled maintenance mode with a themed page, an httpOnly bypass cookie for the operator, and a Cache-Control: no-store to prevent edge-cache stickiness after toggle-off. Plus a real README to replace the v0 auto-generated stub.
Phase 6B — Speed Insights + CHANGELOG + /now + per-post OG (PR #10)
- Vercel Speed Insights for real-user Core Web Vitals
- Public Keep-A-Changelog
/nowpage following the nownownow.com convention- Per-post OG/Twitter cards (each blog post gets its own preview image)
Phase 6B.1 / 6B.2 — Landing UX polish + /now staleness (PRs #11, #12)
- Top scroll progress bar + side section dots for the long landing page
- Cross-page hash navigation that actually works (Next.js prerendering was eating the browser's native scroll-to-anchor)
/nowstaleness safety net: yellow pill at 90 days, red banner + nav-link auto-hide at 180 days. The page itself nudges me when it goes stale.
Phase 6C — FlexSearch + Lighthouse CI + axe-core (PR #13)
Ended up choosing FlexSearch over Pagefind (RSC streaming output isn't clean static HTML for Pagefind to crawl), and local next start over Vercel preview URLs (hermetic, no token setup).
PRs #14, #15 — fixing what the new CI surfaced
- #14: GitHub Actions'
upload-artifact@v4silently drops hidden directories by default..nextstarts with a dot. The build job uploaded an empty artifact, downstream jobs failed onnext start. Found via stack trace, fixed withinclude-hidden-files: true. - #15: With the artifact fix in place, axe-core finally ran — and found two real bugs I'd shipped: two unnamed
<nav>landmarks (header desktop nav + footer "Quick Links" nav) and a heading-order skip on/projects(h1 → h3, missing h2). Both fixes landed.
Things I learned, things that bit, things that surprised me
The cipher-bug fix, twice. The cipher animation that staggers glyphs into the hero <h1> was elegant in principle and a CLS minefield in practice. My first fix (one ghost element + one absolute overlay) gave the parent the right total bounding box, but the overlay was free to wrap differently because cipher glyphs are ~10–15% wider on average than alphabetic ones. So during animation, the overlay would wrap to three lines while the ghost was at two — and overflow visibly out of the bento cell. The second fix split each non-whitespace character into its own inline-block ghost slot. Width is locked per character; cipher glyphs can briefly overflow their slot horizontally during animation but never push other characters to a new line. Playwright verified zero CLS across nine 100ms samples.
Vercel doesn't auto-redeploy on env-var changes. Obvious in retrospect, embarrassing during the rollout: I flipped CONTACT_FROM_EMAIL after Resend verified the domain, expected the next form submission to use the new value, watched it keep using the old one. Env vars bake at build time, not on each request. Manual redeploy fixed it. Now I always pair env-var changes with "click redeploy" in my head.
Cloudflare's "orange cloud" + Vercel = 525 errors. When you add Vercel's A-record at Cloudflare, the proxy toggle defaults to ON. Vercel handles SSL and CDN themselves. Two CDNs in series do not handshake politely. You want DNS only (gray cloud) for the Vercel records.
upload-artifact@v4 dropping hidden files is the kind of thing that would have cost me hours if my axe-core job had been the only failing thing. Two job failures in one run made the pattern obvious — both jobs needed .next and both jobs were missing it. Tail of the stack pointed at the artifact, and the v4 changelog confirmed the breaking default change.
Per-character ghost layout is not just for cipher animations. Anytime you have a layer that animates the characters of a line (typewriter effects, decode-on-scroll, etc.) and the layer's content has different average width than the final text, you'll hit CLS. The fix generalizes.
Pagefind expects a static HTML site. Next.js 16 with RSC + View Transitions does not produce a fully traditional static HTML site — content goes through .rsc payloads and flight chunks. Pagefind's postbuild crawler can't cleanly index that. At small content volumes (10–25 entries) FlexSearch over a concatenated text blob ranks comparably and avoids the postbuild step entirely. Choose deliberate-small over default-big when the default doesn't fit your output shape.
The thing I did right
I committed early and often. Fifteen PRs over ten days isn't because I'm fast — it's because each PR is small, focused, and reverts cleanly. PR #4 introduced the Cmd+K palette additions; PR #7 fixed the cipher bug; PR #14 fixed the artifact bug. None of them required carrying state between them. None of them broke production for more than the few minutes between push and merge. The CHANGELOG reads as a series of small, defensible decisions instead of one heroic rewrite.
This isn't a methodology I invented. It's just what works for a side project where the only reviewer is me. I'd push the same rhythm at work, just with a teammate on the other end of the PR.
What's still imperfect
Things this site has, that you could fault me for not having:
- Real screenshots for the project case studies (most of them are backend-only systems with no UI to capture, but the gradient fallback isn't a screenshot)
- Spanish localization (I'm based in Panama, half my likely audience speaks Spanish — but content duplication cost wasn't justified yet)
- A view counter (Vercel KV pricing changed mid-project, not worth migrating for vanity metrics)
- A newsletter (no subscribers, no posting cadence to support — defer)
- More than four blog posts (this is the fifth — the cadence will come if the work continues to be worth writing about)
These are deliberate non-features. The site doesn't try to be a content machine; it tries to be an honest portfolio. Adding things that would dilute that focus is worse than not adding them.
Closing thought
I rebuilt my portfolio in two weeks of evening work because the version that already existed was misrepresenting me. The replacement isn't perfect, but it's mine — every line of copy is something I'd defend in a conversation, every cert ID resolves, every project description matches a system I actually shipped. That's the bar I think a job-hunt portfolio has to clear, and it's the bar most templated portfolios fail.
If you're reading this from a v0 / Framer / Webflow template you haven't customized: do the truth pass. It's not as much work as you think, and the version of you that exists in the world after the truth pass is a more accurate version than the one before it.
The site you're on is the proof.