Vasu Menon

How I Built This Site

The Weaver by Vincent van Gogh, 1884
The Weaver by Vincent van Gogh, 1884, Museum of Fine Arts Boston

I’ve been wanting to write a post like this for some time. This website has been slowly accumulating some under-the-surface engineering throughout the years, and as I have just moved to a custom domain and set up Cloudflare hosting, I thought that now would be a good time formally discuss this “release”.

The Core: Jekyll

The site, so far1, is purely a static site built with Jekyll. For my purposes, I don’t need a database, a server process, or a build pipeline, and Jekyll gives me a perfect framework for building static sites. The output here is just HTML/CSS/JS, which makes it trivially hostable and quite fast.

Jekyll builds the site from Liquid templates, Markdown files, and _config.yml:

Jekyll builds from Liquid templates, Markdown, and _config.yml:

  • _layouts/ - default.html wraps everything; post.html and note.html extend it.
  • _includes/ - Partials: header.html, footer.html, comments.html, structured-data.html, and figure.html for captioned images.
  • _data/ - YAML/JSON powering the urbex, webring, and other secret pages.
  • _posts/ and _projects/ - Markdown content.

Plugins

Jekyll also provides some cool official plugins, and I’m using a handful of them:

Plugin Purpose
jekyll-feed Generates /feed.xml automatically
jekyll-seo-tag Injects <title>, Open Graph, Twitter card meta tags
jekyll-sitemap Generates /sitemap.xml
jekyll-scholar Academic bibliography from a .bib file, with APA citation rendering
jekyll_picture_tag Generates responsive <picture> elements and WebP variants at build time
jekyll-email-protect Obfuscates email addresses in the HTML output to foil scrapers

The jekyll-scholar integration is one I use on technical posts where I want to cite papers properly. Citations are stored in _bibliography/references.bib and can be rendered in Markdown.

I also wrote2 two small plugins myself.

urbex_media.rb is a Liquid filter that takes a slug and returns a sorted list of all media file paths under assets/images/urbex/<slug>/. The urbex page iterates over entries in _data/urbex.yml, calls this filter per location, and renders a gallery. It keeps the YAML data very clean (I only define a name, a lat/lng, a date, and a description). The plugin figures out what images exist.

file_browser.rb is a Liquid tag that takes a variable name pointing to a list of directory configs and renders a full tree-style file browser widget with search. I use it on the /files/ page to expose my math course notes. It scans the actual filesystem at build time and generates a static HTML tree with a lightweight JS search filter in.

Hosting: Cloudflare Pages

I previously had it “naively” hosted on GitHub Pages, but upon using a Cloudflare domain, I found out that Cloudflare Pages might work the best for the future since they have so much more functionality and way better performance at the edge. It’s still deployed directly from GitHub. Every git push to main triggers a build, and Cloudflare runs bundle exec jekyll build and serves the _site/ output from their vast edge network.

A _headers file at the repo root gets picked up by Cloudflare Pages and sets HTTP response headers:

/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

/assets/css/*
  Cache-Control: public, max-age=31536000, immutable

/assets/js/*
  Cache-Control: public, max-age=31536000, immutable

Static assets are cached3 for some time and HTML pages get default caching. The security headers are just good hygiene (shoutout to my Software Security class)

The Contact Form

My contact email is routed through Cloudflare Email Routing, which forwards mail from noreply@vasumenon.com to my real inbox. It means I never expose my actual address in DNS, and I also get some spam filtering for free.

The contact form is entirely serverless. It submits to /api/contact, which is a Cloudflare Pages Function, a V8-based serverless function that runs at the edge, right alongside the static assets.

The function (functions/api/contact.js)4 does a few things:

  1. Honeypot - there’s a _honeypot field in the form. If it’s filled in (which only an absolute bot would do), the function just returns success.
  2. Validation - field types are checked, lengths are capped (name: 100 chars, email: 254, subject: 200, message: 5000), and a basic email regex runs on the address.
  3. HTML escaping - all user inputs are sanitized before being interpolated into the outgoing HTML email body.
  4. Resend - the sanitized data is sent to the Resend API, which delivers the email to my inbox.

Analytics: Umami

I’m using Umami for analytics. It’s free, open source, privacy-focused, and doesn’t use any cookies. My instance runs at analytics.vasumenon.com, proxied behind Cloudflare, meaning that the analytics script doesn’t get blocked by browser extensions that target well-known tracking domains. I like to think that it evens out karmically since I’m being respectful of your privacy.

Comments: Giscus

Comments are powered by Giscus, which maps each page’s pathname to a GitHub Discussions thread. Comments are stored directly in the website’s GitHub repo. I think this is a very elegant solution for the time being. There’s no need for an external comment database when you can just cleverly use GitHub Discussions instead. Giscus is also very easy to set up.

Math and Maps

MathJax and Leaflet.js are only loaded on pages that need them, controlled by front matter flags:

math: true    # loads MathJax
leaflet: true # loads Leaflet CSS + JS

The layout checks these flags and injects the relevant <script> and <link> tags. This keeps pages that don’t need math or maps free of these payloads entirely.

Leaflet is used on the urbex page to render a map of locations. Each location in _data/urbex.yml has a lat and lng, and the page renders a marker for each one at runtime.

SEO and Structured Data

jekyll-seo-tag handles the standard Open Graph and Twitter card meta tags automatically. On top of that, I have a structured-data.html include that injects a JSON-LD Person schema, which links the site to my LinkedIn and GitHub profiles.

The sitemap is generated by jekyll-sitemap. Secret pages that I don’t want indexed have sitemap: false and robots: noindex in their front matter.

The Webring

The footer has a webring (a left/right arrow pair linking to the previous and next sites in a ring with some friends). I was inspired by Arjun Khandelwal and found it quite charming.

The Odd Pages

There are a couple of pages exist outside the normal blog/projects structure. They’re easter eggs, and I’m keeping them hidden. I will not discuss their engineering here, you have to discover them for yourself first.

—

That’s basically all of it. The whole thing is in a single GitHub repo, builds in a few minutes, and costs nothing to run. There’s no JavaScript framework, no build tool beyond Jekyll, and no runtime infrastructure beyond the Cloudflare Pages Function for the contact form and Umami for analytics. Much of it was also built with AI assistance.

  1. I don’t see a serious need (yet) for a more dynamic website, though I’m secretly hoping for some excuse for me to continue engineering the website further. I’ve had a lot of fun throughout the journey. 

  2. I should say “vibe-coded” here if I’m being completely honest. 

  3. Since the site is built at compile time, CSS and JS changes would be cached indefinitely without cache busting. I handle that by appending ?v=1782037189 to each stylesheet and script URL in the <head>. Jekyll sets site.time to the build timestamp, so every deploy invalidates the cache. 

  4. Also vibe-coded.Â