All posts
15 min read#waline #youtube #nextjs #comments

Comments and videos, without breaking "static-first"

Bolting a guestbook onto a static Next.js site, sourcing video categories from YouTube playlists, and a small detour into picking grey animal silhouettes for anonymous avatars.

A week after the first "how this site is built" post, two more pieces landed: a comment box under every post (a friend asked for it) and a videos page that's actually curated, with categories driven straight from my YouTube playlists (because YouTube is already where I manage them).

Both had to respect the same constraint as the rest of the stack: the browser only ever sees plain HTML, JS, CSS, and PNGs. No server I have to babysit. Here's what ended up working.

Part 1 — Comments without a database (of mine)

The obvious answer is a hosted comment service. The less obvious answer is that almost every well-known one is either ad-supported (Disqus), paid (Hyvor), or US-Big-Tech-coupled in a way that doesn't talk well to readers in China. After a lot of poking I landed on Waline — an open-source, login-free, lightweight comment system that's basically a single serverless function plus a small client.

Waline runs the function on whatever you have lying around — Vercel, Cloudflare Workers, Deno Deploy, Netlify. For storage it supports MongoDB, MySQL, Postgres, SQLite, GitHub-as-a-database, LeanCloud, Cloudflare D1… it pretends to be agnostic.

The storage backend rabbit hole

It is not actually that agnostic. I burned an afternoon learning this:

Backend triedWhat broke
Cloudflare D1Not officially supported. Just don't.
Vercel + Upstash RedisRedis isn't a Waline backend, I just misread the docs.
Vercel + GitHub storageWorked in theory, but Waline crashes when the seed JSON files don't yet exist, and the env-var conventions are undocumented.
Azure Cosmos DB (Mongo API)ResourceRequest timeout. The Cosmos load balancer hands the driver the internal replica-set hostnames during ismaster, the driver tries to connect to them, hangs forever. directConnection=true didn't help.
MongoDB Atlas M0Same timeout. Waline's think-mongo adapter only reads MONGO_HOST/PORT/USER/PASSWORD/DB. It doesn't parse mongodb+srv:// URLs and silently ignores MONGO_OPTIONS.
Neon Postgres (free tier)Worked on the first try once I ran Waline's official assets/waline.pgsql schema in the SQL editor.

The takeaway: if you don't already have a database, just pick the simplest managed Postgres you can find and skip the Mongo-flavoured detours.

Wiring it into a static Next.js page

The component is a thin client wrapper. Three things matter:

  1. Render nothing in production until the backend exists. The whole widget is gated on process.env.NEXT_PUBLIC_WALINE_SERVER. If it's unset at build time, the section disappears entirely; in dev it shows a small "backend not configured" hint.
  2. Dynamic import the SDK. Waline's client + CSS together are ~80 KB. With a static import they'd be in the initial JS for every page. So:
    await import('@waline/client/style');
    const { init } = await import('@waline/client');
    That keeps the first paint clean and only pays the cost on pages that actually mount the widget.
  3. Clean up on navigation. The component's useEffect calls init({...}) on mount and instance.destroy() on unmount, and re-keys on pathname so client-side navigation between posts works.

A bilingual gotcha

Waline keys every thread by the page's path. My site has two locales, /en/blog/foo and /zh/blog/foo, which look like two different paths. Result: a comment left on the Chinese page is invisible to readers on the English page, and vice versa.

The fix is one line:

const walinePath = pathname.replace(/^\/(en|zh)(?=\/|$)/, '') || '/';

Strip the locale prefix before handing the path to Waline. Both routes now talk to the same thread.

Avatars, or: most people won't type their email

Out of the box, Waline asks Gravatar for avatars. If no email is provided, Gravatar returns a deterministic geometric pattern based on… nothing, really — every anonymous comment looks identical. The default fallback also happens to be loud and a bit ugly against the paper-coloured theme.

What I wanted: grey animal silhouettes, picked deterministically, so "Jackie left two comments" always shows the same animal next to both. Three small pieces:

  1. Pick the art. OpenMoji's black SVG set is CC-BY-SA, well-drawn, and includes everything from 🐨 to 🦘 to 🦦. I downloaded 21 of them into public/avatars/openmoji/ and added a NOTICE.txt for attribution.
  2. Override what Waline rendered. Waline doesn't expose an avatar slot you can plug into, so the component installs a MutationObserver on its host container and rewrites any img.wl-user[-avatar]? src it sees as Waline appends comment cards.
  3. Seed the choice well. Hashing the existing Gravatar URL gives every no-email visitor the same animal (the Gravatar fallback URL is the same string). Instead, the seed combines the nickname with the comment's backend id (Waline puts the objectId on .wl-card[id]):
    • Two different anonymous comments still get different animals.
    • The same named visitor stays consistent in the same thread.
const card = img.closest<HTMLElement>('.wl-card');
const nick = card?.querySelector('.wl-nick')?.textContent?.trim() ?? '';
const cardId = card?.id ?? '';
const seed = `${nick}|${cardId}`;
img.src = ANIMAL_AVATARS[hash(seed) % ANIMAL_AVATARS.length];

Total payload: ~60 KB of static SVGs, served straight off the CDN.

Part 2 — Videos, with YouTube as the source of truth

The nice thing about YouTube is that the playlist UI is already a great curation surface: drag to reorder, rename freely, add a video to multiple playlists. Instead of building a second admin in my own repo, the build script just reads what's already there.

Playlists as tabs

For each channel playlist, the sync script calls:

GET /youtube/v3/playlists?channelId=<id>&part=snippet,contentDetails
GET /youtube/v3/playlistItems?playlistId=<each>&part=contentDetails

It records (id, title, description, videoIds[]) for each — and critically keeps videoIds in the curator's order (YouTube returns them in the order you arranged them in the UI).

The videos page is then a tiny client component:

  • One tab per playlist that contains at least one of your uploads. Plus an "All" tab as default.
  • Clicking "All" → newest first. Clicking a specific playlist → sorted by the playlist's videoIds[] order.
const filtered = active === ALL
  ? videos                                 // newest first
  : playlist.videoIds                      // YouTube-curated order
      .map((id) => byId.get(id))
      .filter(Boolean);

The whole "reorder videos on my website" feature is now: drag them on YouTube, wait for the next nightly rebuild. Zero admin UI to build, and nothing to maintain when categories change.

The home page used to have an empty placeholder where "featured videos" would go. Now it just renders the two most recently uploaded videos as thumbnails linking out to YouTube — also driven by the same .cache/videos/videos.json.

Both features are now live. If you want to see them work, the comment box is right below this post — and yes, you'll get a random grey animal.

Share this post

Comments