Why I Built This
Most developer portfolios are brochures. They solve a presentation problem — make me look hireable — and stop there. I wanted to solve a distribution problem instead.
I write one MDX file. The site turns it into a rendered page, a plain Markdown endpoint for LLMs, an RSS feed entry, and a searchable text index. One source, four formats. Press publish, and humans reading Chrome, AI models ingesting plain text, crawlers indexing structured data, and feed readers polling RSS all get fresh content — from the same pipeline, in the same deploy.
Everything else exists to support that pipeline. OAuth so the guestbook works. PostgreSQL so entries persist. Resend so the contact form sends real emails. PostHog so I know what people actually read. None of it is decorative — every dependency earns its place.

Architecture Overview
Three layers, deliberate boundaries.
Content layer — MDX files on disk, validated by Zod at build time, wrapped in React cache() for request-level deduplication. Every content route is statically generated — revalidate = false, dynamicParams = false. No runtime data fetching. The filesystem is the database.
Application layer — Next.js App Router with React 19 and the React Compiler. Server actions handle every mutation and return typed result objects instead of throwing — callers own their side effects. No middleware file; routing logic (social redirects, .md rewrites, PostHog proxy) lives entirely in next.config.ts. Environment variables validated at build time via @t3-oss/env-nextjs — empty strings coerce to undefined, missing secrets fail the build before anything ships.
Infrastructure layer — PostgreSQL via Prisma, better-auth for OAuth, Resend for transactional email, PostHog via first-party reverse proxy. All /ingest/* traffic routes through the app domain — no third-party requests, no ad blocker issues. Discord webhooks fire on form submissions and guestbook entries. Every external service fails silently: a Resend outage doesn't break guestbook submission, a Discord timeout doesn't block the contact form.
Client state is scoped to three Zustand stores (sign-in modal, contact drawer, guestbook with optimistic deletes) and a Cmd+K command palette that indexes every page at module load for instant fuzzy search.
Content Pipeline
Every piece of content starts as an MDX file. gray-matter extracts frontmatter, Zod validates it against typed schemas, reading-time calculates duration, and next-mdx-remote/rsc renders the result with rehype-slug for heading anchors and Shiki for syntax highlighting. Custom MDX components — Callout, Section, Media, FileTree, Steps, CodeBlock, Accordion — extend what Markdown can express without leaving the authoring format.
Single source, four formats:
- HTML — the rendered page with custom components and syntax highlighting.
- Plain Markdown — JSX stripped via regex, served at
/{page}.mdURLs through Next.js rewrites. LLMs get clean Markdown without parsing HTML. - RSS 2.0 — full-content feed at
/blog/rss.xmlwith author metadata and category tags from frontmatter. - LLM text indices —
/llms.txtreturns a structured index with links;/llms-full.txtinlines every post and project body into one response.
The table of contents extracts h2/h3 headings, skips code blocks, and generates slugs via github-slugger. It renders as a sidebar that tracks the active heading through IntersectionObserver — scroll down, and the sidebar follows.
Answer Engine Optimization
Search is shifting from links to AI-generated answers. If your content can't be parsed by an LLM, it doesn't exist in that world. So every page on this site speaks two languages: rich HTML for humans, clean plaintext for machines.
Structured data
Every page emits JSON-LD via type-safe generators built on schema-dts — Person and WebSite on the root layout, Article on blog posts, CreativeWork on projects, BreadcrumbList on nested routes. The metadata factory sanitizes titles to 60 chars and descriptions to 155 chars at word boundaries. Google bot directives enable max-snippet: -1, max-image-preview: large, and max-video-preview: -1 for rich snippet eligibility.
Machine-readable endpoints
Every content page has a .md counterpart:
| Human URL | Machine URL |
|---|---|
/blog/some-post | /blog/some-post.md |
/projects/some-project | /projects/some-project.md |
/about | /about.md |
These are Next.js rewrites to /api/md/* route handlers that strip JSX via regex — imports, self-closing components, block-level tags — and return text/plain with frontmatter metadata intact. No HTML parsing required. /llms.txt and /llms-full.txt provide bulk access, cached at 1hr client-side and 6hr at the edge.
RSS
The feed at /blog/rss.xml generates standards-compliant RSS 2.0 with full content bodies — not excerpts — plus author metadata, category tags, and post images. Feed readers and AI aggregators that poll RSS get fresh content automatically.
Traditional SEO
- Dynamic sitemap — per-type priorities (blog 0.7, projects 0.6, static 1.0),
publishedflag filtering, canonical URLs on every page. - OG images — dynamically generated at
/api/ogwith type-specific layouts. Twitter cards get separate truncation limits because Twitter's preview parser is stricter than OpenGraph. - Security headers —
X-Frame-Options,X-Content-Type-Options, strict referrer policy, permissions policy disabling camera, mic, and geolocation. - Web manifest — dynamic PWA metadata with theme colors and icons. Rewrite maps
/manifest.jsonto/manifest.webmanifestfor broad client compatibility. - robots.txt — dynamic generation, points to the sitemap, allows all crawlers.
Performance Engineering
The most expensive things on this site are GPU-bound: a WebGL globe, a shader background, canvas sparkles, and a carousel with autoplay. None of them run off-screen. Every one is gated by IntersectionObserver — mount once, observe always, animate only when visible.
- 3D globe (
cobe) —useInViewwith 200px margin. TherequestAnimationFrameloop stops entirely when scrolled out. Spring-based pointer drag only calculates while visible. - Shader background — speed drops to 0 when out of viewport. The GPU fragment shader stays compiled but does zero work.
- Canvas sparkles — raw
IntersectionObserver(no library) pauses the RAF loop. Canvas stays mounted, draw cycle stops. - Embla carousel —
useInViewwith 100px margin controls autoplay. No transitions fire off-screen.
No rogue animation loops burning CPU in the background.
Adaptive rendering
usePerformanceMode() reads three signals — core count (<= 4), viewport width (< 768px), and prefers-reduced-motion — and degrades GPU-heavy components accordingly. The shader disables, the globe simplifies. Debounced resize at 150ms keeps it responsive without thrashing.
Load deferral
Nothing loads until it has to.
- PostHog — singleton with lazy
import(). First user interaction triggers the download. - Vercel Analytics / SpeedInsights —
next/dynamicwith SSR disabled. - Motion — ships only
domAnimationviaLazyMotion(~15KB vs ~34KB). - React Compiler — handles memoization at build time. Zero manual
useMemooruseCallbackin the entire codebase.
Build-time optimizations
inlineCss— eliminates render-blocking stylesheets in production.removeConsole— strips everything excepterror.optimizePackageImports— tree-shakes lucide-react, date-fns, motion, cobe, embla-carousel-react, and @paper-design/shaders-react.- Images — served as AVIF/WebP with 1-year cache TTL.
Backend Systems
Authentication runs through better-auth with GitHub and Google OAuth, backed by a Prisma adapter on PostgreSQL. A single catch-all route at /api/auth/[...all] handles every flow. On the client, signIn, signOut, and useSession export with full type inference. PostHog fires auth_signed_in with the provider on completion, and identify() ties analytics sessions to authenticated users.
The guestbook is the most complete end-to-end feature. A visitor signs in through the OAuth modal, writes a message (Zod-validated, 5-100 chars), hits a rate limit check (3 per 5 minutes via an in-memory sliding window), and the server action creates the Prisma record, sends a confirmation email through Resend, fires a Discord webhook, and revalidates the page — all in one server action. Deletion is author-only with a 5-second undo toast. Soft deletes via a published boolean keep the data intact, and onDelete: SetNull preserves entries even if the user account is removed.
The contact form follows the same pattern: Zod validation, rate limiting (3 per 15 minutes per IP), then resend.batch.send() fires two emails in a single API call — owner notification with IP and geolocation from Vercel headers, and a sender thank-you. Discord gets a webhook with a blue embed. Every external call is wrapped so failures never surface to the user.
Key Decisions
MDX over a headless CMS
No API latency, no vendor lock-in, no content/code drift. MDX gives React component embedding, git gives versioning, Zod gives schema validation. The tradeoff — non-technical editors can't contribute — is irrelevant for a personal site.
better-auth over NextAuth
Prisma adapter that works with my schema out of the box, a clean plugin system, and full TypeScript inference from server to client. nextCookies() handles App Router edge cases transparently.
Typed results over thrown errors
Server actions return result objects with a success/failure shape instead of throwing. Callers pattern-match and own their response — toasts, form resets, redirects. The type system enforces exhaustive handling. No try/catch chains, no error boundary gymnastics.
What I Learned
Content infrastructure outlasts content. The MDX pipeline, AEO endpoints, and metadata factory pay dividends every time I publish. Write the file, and four formats update. That investment has already saved more time than it took to build.
First-party analytics infrastructure isn't optional. The PostHog reverse proxy doubled data collection overnight. Third-party domains get blocked by ad blockers, which means biased samples and phantom drop-offs. Routing through the app domain fixed that completely.
Performance is a design constraint, not polish. IntersectionObserver gating, adaptive rendering, and deferred loading shaped the component architecture from day one. Treating performance as a hard requirement — not a "nice to have" — forced better abstractions across the board.
Auth is the simplest hard problem. The OAuth flow is trivial. Session management across server components and client hooks, cookie handling in App Router, making sign-in feel instant while three redirects happen behind the scenes — that's where the real complexity lives.