Building This Site
5 min read
Updated Jun 13, 2026, 2:03 PM SGT.
I wanted a personal site that was minimal, fast, and easy to update — something I owned end to end, with no CMS to log into and no database to babysit. What follows is the honest dev-log of how it came together, from an empty repo to the site you're reading now.
A quick note on process: I built most of this by pairing with Claude Code, Anthropic's coding agent. I'd describe what I wanted, we'd talk through the trade-offs, and it would draft the code while I reviewed, tested, and steered. It felt fitting — the site even ships its own AI assistant, so a fair bit of it was AI all the way down.
The foundation#
The first commits, back in March, were the unglamorous part: a fresh Next.js 16 project on the App Router, TypeScript in strict mode, and Tailwind CSS v4. From there I laid out the full shape of the site in one go — navigation, footer, and the seven sections I knew I wanted: Home, About, Blog, Projects, Volunteer, Resume, and Contact.
Getting the skeleton right early mattered. Once the routes and layout existed, every later feature had an obvious place to live.
Content is just Markdown#
The core decision was to treat content as plain Markdown files in the repo. Each post, project, and volunteer entry is a .md file with a little YAML frontmatter on top. At build time they're parsed with gray-matter, run through a unified pipeline (remark → rehype), and turned into static HTML. No database, no CMS, nothing external to go down.
Two things made this pleasant to live with rather than fragile:
- Typed, validated frontmatter. Every file's metadata is checked against a Zod schema, so a typo in a date or a missing title fails the build instead of shipping broken. (That
updatedstamp at the top of this post is a real, validated field.) - A proper rendering pipeline. GitHub-Flavored Markdown, automatic heading anchors, and syntax highlighting via Shiki with matching light and dark themes. Reading time is computed from the word count, so every post shows an honest estimate.
Adding a post really is just dropping a new .md file in a folder — which is the whole point.
Design and identity#
The look went through a few iterations. It started as a clean white base with two restrained accents — a KPMG blue and a Singapore red — and later grew into a small four-element palette (water, fire, earth, and wind: blue, red, amber, and green) used sparingly across the UI.
The type is Inter, there's a faint dot-grid texture in the background, and the motion is deliberately subtle: gentle card hover lifts, view transitions between pages, a scroll-progress bar on posts. All of it respects prefers-reduced-motion, so if your system asks for calm, the site stays still.
Dark mode#
Dark mode is class-based — toggled by hand and remembered in localStorage, falling back to your system preference if you've never chosen. The fiddly part was the flash: a tiny inline script sets the theme before the page paints, so you never catch a flicker of white on the way to dark. A couple of follow-up fixes later — wiring up Tailwind v4's class-based dark mode and stopping the system preference from clobbering a saved choice — and it finally behaved.
Making it discoverable#
I wanted the site to be easy to find, for people and for machines alike, so it got a proper SEO and "GEO" (generative-engine optimization) pass:
- Per-page metadata, canonical URLs, and JSON-LD structured data (Person, BlogPosting, breadcrumbs).
- Dynamically generated Open Graph images, so links unfurl nicely when shared.
- A sitemap, an RSS feed at
/feed.xml, and arobots.txtthat actively welcomes crawlers. llms.txtandllms-full.txt— plain-text summaries of the whole site aimed at AI crawlers, so a model answering questions about me has clean source material to work from.
Blog niceties and search#
Posts carry tags, each tag has its own page, and every post suggests related and adjacent reading. On top of that there's client-side search: at build time the site generates a small JSON index, and the search page filters it instantly in the browser — no server round-trip.
The AI chat widget#
This is my favourite part. There's a chat widget that answers questions about me, grounded in the actual site content. It's powered by Anthropic's API, and its system prompt is assembled from everything on the site — blog posts, projects, volunteer work, resume — so it answers from real material rather than inventing things.
It's also built to behave responsibly: conversations are validated and length-capped, you can stop or regenerate a reply mid-stream, and requests are rate-limited per visitor (backed by Redis when it's available, with an in-memory fallback when it isn't).
Resume as data#
The resume is the one place I went structured rather than Markdown. It lives as typed data, which lets each role, skill, and credential have its own dedicated page and powers a small skills-to-experience graph that links them together. I later imported my actual LinkedIn profile to flesh it out and added a languages section.
Testing, CI, and shipping#
A personal site doesn't strictly need tests, but I wanted to change things without fear. So there are Vitest unit tests around the content parsing and helpers, a Playwright smoke suite that loads the built site and clicks through it, and a GitHub Actions pipeline that runs lint, formatting, and both test suites on every change. ESLint and Prettier keep everything tidy.
Hosting is Vercel. Almost everything is statically generated, with a single serverless route for the chat endpoint — fast to load and cheap to run.
What's next#
The foundation feels solid now, so from here it's mostly content and polish: more writing, the occasional design refinement, and whatever small ideas seem fun. The nicest thing about this setup is how low-friction it's become — the next post is just another .md file.