Live Webmentions Without SSR: A Hybrid Approach
Upgrading the WebMentions component from build-time-only to a hybrid model: static HTML renders instantly, a silent client-side fetch updates the count without a redeploy.
Category: Development
The first version of my WebMentions component fetched from webmention.io at build time and baked the results straight into the static HTML. It worked, but the obvious limitation showed up within a day: a new like or reply only appeared after the next deploy. On a site that rebuilds once a day, a reaction from this morning might not surface until tomorrow.
The setup
- Site: Astro 6 static build, deployed via a shell script that rsyncs to a VPS.
- Data source: webmention.io, fed by Bridgy, which relays likes and replies from Bluesky and Mastodon.
- Constraint: no SSR — the rest of the site is pure static HTML served from a container.
Why not SSR
Problem: SSR would solve the staleness in one line — fetch fresh data on every request, render, send. The cost is what it breaks: cold-start latency on every page load, a server process to keep alive, and the loss of static delivery from a CDN edge.
The webmention component is the only dynamic element on otherwise fully static pages. Switching the whole render pipeline for one component was the wrong tool.
The hybrid approach
Implementation: keep the static build, add a silent client-side refresh.
- Build time — the component fetches webmention.io and renders complete HTML. Visitors see mentions immediately, no loading state, no flash of empty content.
- Client side — after the page loads, a small script re-fetches the API. If the count changed since the build, it updates the DOM. If nothing changed, it does nothing.
No spinner, no visible transition. The static render is correct at build time; the client fetch catches anything newer.
The build-time fetch
The Astro frontmatter handles the build-time side:
let mentions: Mention[] = [];
try {
const res = await fetch(
`https://webmention.io/api/mentions.jf2?target=${encodeURIComponent(url)}&per-page=100`
);
if (res.ok) {
const data = await res.json();
mentions = data.children ?? [];
}
} catch {
// Unavailable at build time — client-side will handle it
}
A network hiccup at build time isn’t fatal — the component renders empty and the client-side script picks it up on the next page view.
When there are zero mentions at build, the template emits a hidden placeholder:
{mentions.length === 0 && (
<section class="webmentions" data-url={url} data-empty></section>
)}
The data-empty attribute triggers display: none in CSS. If the client fetch later finds mentions, it strips the attribute and injects the rendered content.
The client-side refresh
The script runs once on page load and only re-renders when the count changed:
async function refreshWebmentions(section) {
const url = section.dataset.url;
const res = await fetch(
`https://webmention.io/api/mentions.jf2?target=${encodeURIComponent(url)}&per-page=100`
);
const data = await res.json();
const mentions = data.children ?? [];
const currentCount = section.querySelectorAll("[data-wm-id]").length;
const isEmpty = section.hasAttribute("data-empty");
if (mentions.length !== currentCount || (isEmpty && mentions.length > 0)) {
if (mentions.length > 0) renderMentions(section, mentions);
}
}
document.querySelectorAll(".webmentions[data-url]").forEach(refreshWebmentions);
data-wm-id is stamped on each mention element by the static render — counting those gives the current display count. If the API returns more or fewer, the section re-renders. On most page loads the count matches and nothing happens.
The CSS scoping problem
Problem: Astro scopes component styles by adding a data-astro-cid-* attribute to every element in the template and restricting generated selectors to that attribute. Fine for static markup — but when the client script writes HTML via innerHTML, the new elements don’t get the scoped attribute and the styles silently stop applying.
Implementation: <style is:global>:
<style is:global>
.webmentions { ... }
.webmentions__avatar { ... }
</style>
Plain class selectors without the scoped attribute. They apply equally to the static render and to dynamically injected content.
Solution: the tradeoff is global scope. Acceptable here — the .webmentions__ BEM-ish naming convention is specific enough to avoid collisions with anything else on the site.
What changed
- Visitors see webmentions immediately on page load — no loading state.
- New interactions appear without a redeploy, as soon as Bridgy polls and forwards to webmention.io.
- No SSR overhead — the page is still fully static HTML served from a container.
- The client-side script is small, runs once, and is invisible unless the count changes.
What to take away
- SSR for one component is a bad trade. Static first, client-side refresh for the one dynamic bit.
- Render complete HTML at build time. No loading state means no flash — the visible content is always correct at the moment of deploy.
- Count-based diffing avoids pointless DOM work. If the API returns the same count, skip the re-render.
is:globalis the escape hatch when you need the same styles to cover both SSR-ish static markup andinnerHTML-injected content.- Empty-state placeholders matter. A hidden
data-emptysection is the hook the client script uses to fill in mentions that didn’t exist at build.