Portfolioโ€บkeateswebsiteTypeScript

keateswebsite

Portfolio website - Next.js and Payload CMS๐Ÿ—“๏ธ Last updated September 10, 2026

michaelkeates.co.uk

Personal portfolio and blog, live at www.michaelkeates.co.uk โ€” a fully self-hosted, content-managed rebuild of my original Next.js site, keeping its glassmorphism look while making every part of it editable from a CMS.

Tech stack

Layer Choice
Framework Next.js 16 (App Router, React Server Components)
CMS Payload CMS 3 (Postgres adapter, blocks, globals, native dashboard widgets)
UI Chakra UI v3 + Tailwind 4, framer-motion fade-ups, glassmorphism design system
3D three.js โ€” an interactive avatar model on the home page
Database PostgreSQL 16
Cache Valkey 8 (open-source Redis fork) via a Next.js cache handler
Runtime Docker Compose on Proxmox VMs โ€” separate dev and production environments
Git hosting Self-hosted Gitea
Edge Cloudflare Tunnel (no open inbound ports)

Highlights

  • Everything is content-managed. Pages are composed from reusable Payload blocks โ€” profile header, experience timeline (LinkedIn-style multi-role support), certifications, icon grids, social icon rows, layout grids, video/X embeds โ€” so new sections need no code. Site title, favicon, background colours, a tabbed settings panel, search and social buttons are all CMS settings too, and rich text supports per-selection fonts alongside the usual formatting.
  • Configurable mega-menu: a CMS-built navigation dropdown with per-section column grids (1โ€“4 across), a Lucide icon on any link, and an auto-populating "latest posts" section โ€” no code to add or rearrange menu items.
  • Real-time live preview: editing a post updates the preview as you type (Payload's useLivePreview), and the admin light/dark toggle cross-fades smoothly.
  • Interactive 3D avatar with a seasonal wardrobe: the CMS schedules alternative models by date range (a Halloween ghost with a "BOO" animation, Santa, shirt and cap variants), all generated procedurally with three.js scripts in this repo.
  • Theme sync: the site's light/dark toggle and the Payload admin share one cookie, so flipping either side updates both.
  • Live ops dashboard inside the Payload admin: content stats, Valkey cache metrics with a live performance graph, and Cloudflare analytics โ€” 7-day totals, a 30-day traffic chart, a world bubble-map of requests by country, and a top-countries list, all fed by Cloudflare's GraphQL API. A team message board widget lets admins post feature requests and replies (with avatars and status) right on the dashboard.
  • WordPress importer: upload a WXR export to pull categories, media, posts and pages straight into Payload, with an optional overwrite mode to re-sync updates from WordPress.
  • Portfolio straight from git: the Portfolio page lists repositories live from my Gitea instance, and each project page renders the repo's README server-side โ€” this very document is rendered there.
  • Multi-site from one codebase: the same repo powers independent deployments (my personal site plus client sites) โ€” each its own VM, database, domain and branding, driven entirely by env vars and CMS content with no code forks. A fresh install builds its whole schema automatically from Payload migrations on first boot.
  • Production hardening: immutable Docker image builds, named volumes, nightly database + media backups with retention, and a Cloudflare Tunnel so the origin exposes nothing to the internet.

Architecture

Cloudflare (DNS + Tunnel)
        โ”‚
        โ–ผ
  Production VM โ”€โ”€ docker compose: Next.js (standalone build) ยท Postgres ยท Valkey
        โ–ฒ                                            โ”‚
        โ”‚  git pull main + image rebuild             โ”‚ content authored here
        โ”‚                                            โ–ผ
     Gitea (self-hosted) โ—„โ”€โ”€ push dev / merge main โ”€โ”€ Dev VM (hot reload, schema push)

Code flows dev โ†’ Gitea โ†’ production; content flows the other way (production is the source of truth, dev refreshes from its backups). Dev uses Drizzle's schema push for fast iteration; each schema change is captured as a Payload migration that every production instance applies automatically on deploy โ€” so one main branch safely fans out to multiple sites.

Development

cp .env.example .env   # set secrets
docker compose up -d   # dev stack: next dev with hot reload

Production runs the same repo through docker-compose.prod.yml (multi-stage standalone build) and deploys with a single script: pull main, rebuild the image, restart the stack.

Full setup and operations documentation lives in docs/SETUP.md, and the process for standing up a new client site in docs/NEW-CLIENT.md.