This site is best experienced on a laptop or desktop.

Colophon

How this site is built. I like sites that are open about their stack and decisions, so here is mine.

Source: GitHubDeployed on VercelDNS via Cloudflare

Frontend

  • Next.js 16 (App Router)

    The entire site is a Next.js application - a framework that handles both the user-facing pages and the server-side logic in one codebase. I use the App Router, which lets me choose on a per-page basis whether content is built on the server (faster initial load, better for SEO) or in the browser (needed for anything interactive like the live status widget). Most pages are server-rendered and sent to you pre-built.

  • TypeScript

    TypeScript is JavaScript with a strict type system layered on top. Every piece of data in this site has a defined shape - blog posts, project entries, API responses, all of it. This means the editor can catch mistakes before the code even runs, which matters when a lot of things are interconnected. Strict mode throughout with no exceptions.

  • Tailwind CSS

    Tailwind CSS is a utility-first CSS framework - instead of writing separate stylesheet files, styles are applied directly as class names in the HTML. It keeps styling co-located with the component it applies to, which makes maintenance straightforward. I combine it with shadcn/ui (see below) for more complex interactive components.

  • shadcn/ui

    shadcn/ui is a collection of pre-built, accessible UI components that I own the code for - dialogs, dropdowns, tooltips, badges, the command palette. Unlike a traditional component library where you import a package you cannot change, shadcn/ui components live directly in the codebase and can be modified freely. Built on Radix UI primitives, which handles the tricky accessibility behaviour (keyboard navigation, focus trapping, ARIA attributes).

  • Framer Motion

    Framer Motion is used sparingly for the entrance animations on the homepage hero section. The staggered fade-in as the page loads is handled here. I deliberately keep motion minimal on the rest of the site - animation should enhance content, not compete with it.

  • Lucide React and React Icons

    All icons across the site. Lucide React for UI icons (arrows, checks, chevrons, status indicators and so on). React Icons for brand logos where Lucide does not have an official one - GitHub, LinkedIn, Spotify, Discord and similar.

  • Geist

    Geist is the typeface designed by Vercel and used across this site. Geist Sans for all body text, headings and UI labels - clean and highly legible at any size. Geist Mono for timestamps, file paths, code snippets and technical labels where fixed-width spacing matters. Both are loaded as Next.js font optimisations so they are never fetched from an external CDN.

  • next-themes

    next-themes manages the light and dark mode toggle. It stores your preference in localStorage so the site remembers which theme you chose across visits. The 150ms crossfade on toggle is handled here. No flash of the wrong theme on page load.

  • Giscus

    The comment system on blog posts, powered by Giscus and backed by GitHub Discussions. When you leave a comment it is stored as a GitHub Discussion on the public repo - no separate database, no third-party ad-funded platform. Giscus loads the discussion thread for each post by matching the page URL to a discussion. You need a GitHub account to comment. The widget respects the site theme and switches between light and dark automatically.

Backend and data

  • Vercel

    Vercel is where the site is hosted and deployed. Every time a change is merged to the main branch on GitHub, Vercel automatically builds and deploys the new version within about a minute. Preview deployments are also created for every pull request so changes can be reviewed at a live URL before they go public. The domain and SSL certificate are managed here too.

  • Upstash Redis

    Redis is a data store that keeps everything in memory rather than on disk, which makes reads and writes extremely fast. I use Upstash's serverless version for anything that changes frequently and needs to be retrieved quickly: live device status from the daemons, the last Spotify track I played (a fallback shown when nothing is on), blog post reaction counts and rate limiting on the contact form. Redis is not a traditional database - it is a short-term, high-speed cache.

  • Next.js API routes

    All the server-side logic lives in route handlers inside the Next.js app. When the live status widget asks 'is the PS5 online?', it is calling one of these routes, which in turn reads from Redis. The Spotify now-playing card, the GitHub activity strip, the contact form submission, blog reactions - each is a separate server-side function that runs on demand. None of this logic runs in your browser.

  • Content data

    All content on this site is stored as typed TypeScript files. Blog posts live in data/blog/, TIL entries in data/til/, project listings in data/projects.ts, research publications in data/respub.ts and consumed media in seven per-category files under data/consumed/ (videos, podcasts, books, music, articles, resources, others). There is no external CMS, no database and no third-party content API. Everything is written directly as code, versioned in Git alongside everything else, and renders instantly with no database round-trip. Blog posts and TIL entries share the same block-based structure: each is an array of explicitly typed blocks (heading, paragraph, list, code, image, quote, note callout, embed) rendered by a shared component. This gives complete control over how every element looks with full type safety throughout.

  • RSS feeds

    Three RSS 2.0 feeds are generated dynamically: blog posts at /blog/feed.xml, TIL entries at /til/feed.xml and newsletter issues at /newsletter/feed.xml. Each feed is styled with an XSL stylesheet so it renders as a clean, readable page in browsers that display it directly rather than as raw XML. Visiting any feed URL in a browser also shows a fully styled HTML view with pagination and thumbnails. Append ?raw to any feed URL to get the raw XML. Add any feed to your reader of choice to receive updates automatically.

  • Resend

    Email delivery for the contact form, handled by Resend. When you submit a message, the name, email and content are sent to a server-side route which calls the Resend API to forward it to my inbox. Nothing is stored in a database - the email is sent and that is it. Submissions are also rate-limited via Redis to prevent the form being used for spam.

  • Beehiiv

    Beehiiv is the platform behind the isaacadjei.me newsletter. When you subscribe via the site, a server action calls the Beehiiv API to add your email to the publication. Beehiiv handles list management, sending, tracking and unsubscribes. Every issue has a one-click unsubscribe link at the bottom.

  • GitHub Actions

    GitHub Actions runs automated workflows whenever code changes. The main one generates the CV PDF: whenever cv.html is updated on the main branch, an action runs html2pdf.js in a headless Node environment, converts the HTML to a PDF and commits the file back to the repo. This means the PDF is always in sync with the HTML source without any manual export step.

  • Cloudflare Turnstile

    Cloudflare Turnstile is the bot protection on the contact form. Unlike traditional CAPTCHAs that make you identify traffic lights or buses, Turnstile works silently in the background and only challenges when it suspects bot activity. Server-side verification happens before any email is sent - if the Turnstile check fails, the request is rejected. Free tier, no tracking pixels, no fingerprinting.

Design decisions

  • Dark mode first

    The dark theme is the primary design target - it is what I look at most often and what I optimise for. Both light and dark themes use the same component code; only the CSS custom property values change between them. The preference is persisted across visits and the toggle crossfades at 150ms to avoid a jarring flash.

  • No animations on scroll

    Scroll-triggered animations - things that fade or slide in as you scroll down - are deliberately avoided on most pages. They add visual noise, can cause nausea for users sensitive to motion and make the page feel slower even when it is not. Entrance animations are limited to the homepage hero. Everything else just loads.

  • No city, ever

    The live status widget shows my current country and timezone but never the city. The Mac daemon has access to GPS-level location via CoreLocationCLI but deliberately only passes the country code to Redis. This is a hard privacy line - knowing I am in the UK is useful context for the clock; knowing I am in a specific neighbourhood is not.

  • Structured data, not markdown

    Most developer sites use MDX: markdown files with embedded React components. I went a different route: each blog post and TIL entry is a typed TypeScript object with a content array of explicit block types (heading, paragraph, code, list, quote, image, note callout, embed). A shared block renderer turns these into HTML. The trade-off is more verbose authoring, but the payoff is full control over how every element renders, no MDX compilation step and complete type safety throughout. Because both blog posts and TIL entries use the same block types, the renderer is shared between them.

  • Command palette

    Cmd+I (or Ctrl+I on Windows) opens a site-wide command palette powered by cmdk. You can jump to any page, search projects, toggle the theme and more without touching the mouse. The shortcut is I for Isaac rather than K (the more common convention) - a small personal touch.

  • Share feature

    Projects and blog posts have a share button. On desktop it copies the page URL to the clipboard and shows a brief confirmation. On mobile it opens the native share sheet so you can send the link through any app. The button is deliberately only present on shareable content pages, not on utility pages like Skills or About.

  • Responsive but desktop-first content

    The site is fully responsive and works on any screen size, but the richer content - live status cards, the lab terminal, project galleries - is designed with a larger screen in mind. A slim dismissible banner appears on narrow screens to set that expectation. The banner text is foreground-coloured (not grey) so it is actually readable.

  • Google Analytics (GA4)

    Privacy-conscious page-view analytics via Google Analytics. Fully anonymised - no individual visitor is identified or tracked across other websites. I can see which pages are read most and which content is landing well, which helps me decide what to write next. Nothing personal is collected.

Notable pages and features

  • /lab - interactive terminal, GitHub stats, live coding stats, top picks, PCB viewer, gaming panel

    An in-browser terminal with 30+ commands spanning navigation, content, live stats and personality. Several commands animate theatrically line by line. The coding stats panel has a period selector, stat cards, daily trend line, 7x24 interactive heatmap with the peak coding hour below it, charts and an hour-of-day bar chart. The PCB viewer loads the actual 3D mesh of the audio amplifier board (real GLB model via react-three-fiber and drei) with angle presets, wireframe mode, auto-rotate and a grid; below it are drag-to-orbit copper layer renders, a front/back photo flip card of the built board, an assembled board photo and the full circuit schematic - the latter two open fullscreen in a lightbox on click. The gaming panel shows PS5 and Gaming PC status side by side - game cover art as a banner, online or last-seen badge and CPU plus GPU utilisation bars when the PC is active. The top picks panel has three tabs: Tracks shows a ranked list with duration bars and a listening-era chart grouped by release decade; Artists shows a ranked list with genre tags, a mainstream-vs-underground scatter (your rank against each artist's follower count) and follower bars; Genres shows a rank-weighted genre donut with a breakdown. Genres come from Last.fm (Spotify retired its artist genre data in 2025).

  • /blog - block-based post renderer

    Blog posts are authored as typed TypeScript objects rather than markdown files. A shared block renderer handles every block type: headings, paragraphs, code with syntax highlighting, numbered and bulleted lists, pull quotes, images with captions and reference links (numbered superscript links that compile into a references section at the bottom). Each post also has an emoji reaction bar backed by Redis. The listing page has a text search input and type filter tabs (Blog, Journal, Research, Notes, Report, Article, Resources) that work together with pagination.

  • /til - Today I Learned

    Short, structured notes on things I discover while working. Each entry has a category, date, a lead paragraph and optional detail blocks using the same typed block system as blog posts, so a TIL entry can contain syntax-highlighted code examples, section headings, note callouts, embeds and source links. The listing page has a text search input and category filter pills derived from the entries actually present (no empty categories ever appear). Pagination shows ten entries per page. Each entry has its own permalink at /til/[slug] where the full detail, tags and prev/next navigation are shown. There is a subscribe-in-your-reader RSS feed at /til/feed.xml. Entries span a wide range: embedded systems and firmware, algorithms and data structures, TypeScript and Next.js, Linux internals, Git internals, security concepts, hardware design, music and piano practice, fitness, Ghanaian cooking and culture and faith.

  • /respub - research and publications

    A catalogue of formal research outputs: citable papers, technical notes and open-source curricula. Each entry links directly to its record on Zenodo, ORCID or the relevant platform so it can be found, cited or built on. The page also shows profile links across academic networks (ORCID, Google Scholar, Zenodo, ResearchGate, Academia.edu) in a single row. Data lives in data/respub.ts alongside the other content files. No external academic CMS.

  • /links - social hub

    A single page linking out to every platform I am active on: GitHub, LinkedIn, Spotify, YouTube, ORCID, Goodreads, Chess.com and more. Each platform has its icon and a short description of what you will find there. The page also embeds a live Spotify now-playing card so you can see what is on while you browse. All link data lives in data/links.ts alongside the other content files.

  • /consumed - media tracking

    A public log of everything watched, listened to and read across the year, split into seven dedicated subpages: Videos, Audio, Books, Music, Articles, Resources and Others. Each subpage has a year and month filter. All data lives in per-category TypeScript files under data/consumed/ - same versioned-in-Git approach as the rest of the site. The main /consumed page shows all categories at once in a tabbed view; each tab navigates to its dedicated subpage. Video entries support both single videos and playlists via inline YouTube embeds. Audio entries embed Spotify via the Spotify oEmbed API.

  • /contact - contact form

    The contact form at /contact uses Resend for email delivery and Cloudflare Turnstile for silent bot protection. Submissions are rate-limited via Redis. Nothing is stored - the message goes straight to my inbox and that is it. You can also reach me directly at [email protected].

  • /changelog - public release history

    Every meaningful change to the site is logged here as a versioned entry. Updated manually in CHANGELOG.md and rendered as a timeline. It is a habit I picked up from open-source projects and I find it useful for tracking how the site has evolved over time.

  • OG image generation

    Every page has a dynamically generated Open Graph image at /api/og. When you share a link on Twitter, LinkedIn, iMessage or any platform that shows a preview card, the image is generated on the fly using Vercel's @vercel/og library. It renders the page title and description as a styled card using the Geist font. This is why shared links look intentional rather than blank.

The live status system

  • How it works

    The live status widget on /now, the homepage and /lab shows real data from my devices in near real-time. Background services (daemons) run on each machine and push data to Upstash Redis every 60 to 120 seconds. The site reads them back through short-lived cached endpoints when you load the page, so however many people are viewing, the data is fetched once and shared rather than re-read per visitor. If a device goes offline, Redis keys expire after a short window and the card shows the last known state with a timestamp.

  • MacBook daemon

    A Python script managed by launchd on macOS. It runs in the background at all times and writes to Redis every 120 seconds: battery percentage, charging state, local timezone and current weather. Weather comes from Open-Meteo, a free European meteorological API with no API key required that uses the ECMWF model - more accurate for UK weather than most commercial alternatives. Location is determined via CoreLocationCLI (GPS-level accuracy), and the timezone is derived straight from those coordinates with timezonefinder so the clock follows the exact zone even in countries that span several; ipinfo.io is the fallback for both. Only the country code and timezone are stored - the city is deliberately excluded for privacy.

  • Lenovo and Gaming PC daemons

    Python scripts managed by NSSM (Non-Sucking Service Manager) as proper Windows services - they start on boot, restart on crash and run without a visible terminal. The Gaming PC daemon also reads GPU utilisation via pynvml (NVIDIA's Python library) and detects the currently running game through five escalating tiers: a hardcoded map of known games, the Steam Web API, Epic Games local manifest files, EA App manifest files and finally process-name fuzzy matching against IGDB's game database. Cover art is fetched from IGDB on first detection and cached for the session. The card it feeds mirrors the PS5 one: CPU and GPU as small live graphs built from the polls, the current game while I play and the last game I played once the PC goes offline.

  • Spotify

    A Next.js API route fetches the currently playing track from the Spotify Web API on demand. The OAuth access token (which expires every hour) is refreshed server-side and held in memory, and the now-playing response is cached at the CDN edge for a few seconds so that however many tabs are open, Spotify is polled at most once every few seconds per region rather than once per tab - and Redis is not touched on this path at all. Spotify retired its audio-features and audio-analysis APIs in November 2024, so the visualiser no longer tries to react to the sound - instead it extracts the dominant colours from the album art and renders a sine wave above a bouncy equaliser, both tinted from the cover, on a device-pixel-ratio canvas animated on delta-time and tuned for light and dark mode. Bar peaks darken as they rise like a real meter, and the wave swings wider and darkens in step with the bar beneath each point. Genre tags come from Last.fm (Spotify retired artist genres in 2025). The progress bar ticks every second client-side. The widgets poll two small edge-cached endpoints - a fast Spotify one (refreshed every few seconds, so song changes appear in near-realtime) and a combined snapshot for the devices, GitHub and Discord - and pause while the browser tab is hidden, so many open tabs share one cached response instead of each holding a connection open. When nothing is playing, the last played track is shown in a greyed-out state from a separate Redis key.

  • PS5

    A Cloudflare Worker runs on a cron every 2 minutes and polls the PlayStation Network presence API using a custom OAuth v2 flow written from scratch - no third-party libraries. Sony's session cookie (NPSSO) is exchanged for a short-lived access token and a long-lived refresh token on first run. The refresh token is stored in Cloudflare Workers KV and rotated on each use, so the session stays valid for around 60 days before needing a new NPSSO. Game cover art is fetched from IGDB on each run. The result (online status, game name, cover art, last seen timestamp) is written to Upstash Redis.

  • Discord

    The Discord presence card uses Lanyard, a free open-source API that exposes Discord rich presence data for opted-in users. It shows online status (online, idle, do not disturb, offline), current activity (game being played, VS Code workspace, Spotify playback via Discord) and elapsed time. On /now the card always shows, even offline. On /notes it only appears when I am online. Multiple simultaneous activities stack with type labels.

  • GitHub activity

    The GitHub strip uses the GitHub REST API to show the last repository I pushed to and when. It is fetched server-side and cached in Redis for 5 minutes. My profile repo is excluded so the strip always shows real project activity rather than profile README updates.

Something interesting or something broken? Let me know.