
Portfolio 2025
A custom publishing platform with per-channel content visibility, scoped full-text search, and AI-assisted content management via an MCP API — built on Next.js, Cloudflare Workers, D1/R2, and an OAuth 2.0 + PKCE auth server.
Problem
Most portfolio sites are static, hard to maintain, and quickly become stale. The goal here was to run everything — public site, admin CMS, REST API, URL shortener, and OAuth server — from a single codebase deployed entirely on Cloudflare's edge network: zero cold starts, low latency, no third-party CMS dependency.
Tech Stack
- Framework: Next.js 16 (App Router, RSC, React 19) via OpenNext (@opennextjs/cloudflare)
- Runtime: Cloudflare Workers — Web-standard APIs only (fetch / crypto / Request), no Node built-ins
- Database: Cloudflare D1 (SQLite at the edge) + Drizzle ORM; FTS5 full-text search with snippet highlighting and LIKE fallback
- Storage: Cloudflare R2 for media; client-side compression before upload
- Editor: Tiptap 3 (rich text with colour, links, images, task lists, YouTube embeds)
- Styling: Tailwind CSS 4 + shadcn/ui + OKLCh colour space, flicker-free dark mode
- Validation: Zod 4 + @t3-oss/env-nextjs
- Testing: Vitest + better-sqlite3 (in-memory TDD for the data layer)
- Toolchain: Biome, Wrangler, pnpm
Outcome
A fully functional portfolio and content platform, live at txz.cool.
Public site
- Four content sections — Experiences, Projects, Photography, Blog — each with list, detail, and tag filtering views
- Draggable Polaroid photo stack on the homepage: physics-based random rotation, click-to-expand, SSR-safe with no hydration mismatch
- ⌘K command palette powered by FTS5 — grouped results, highlighted snippets, adaptive debounce (600ms / 400ms / 200ms by query length)
Admin CMS
- Full content lifecycle: draft → publish → unpublish, with scheduled future publishing and one-click index rebuild
- Draft autosave to localStorage — edits survive page refresh
- OAuth 2.0 + PKCE server: complete authorisation flow for third-party integrations
- Bearer token REST API — the same API used to manage this entry via MCP
Data model
- Single
contentstable with atypediscriminator and adataJSON column for type-specific fields — simplifies migrations and keeps queries flat - Tag filtering via D1's
json_each()— no join tables