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 tried
What broke
Cloudflare D1
Not officially supported. Just don't.
Vercel + Upstash Redis
Redis isn't a Waline backend, I just misread the docs.
Vercel + GitHub storage
Worked 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 M0
Same 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:
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.
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:
That keeps the first paint clean and only pays the cost on pages that
actually mount the widget.
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.
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:
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.
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.
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.
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.
Featured videos on the home page
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.