Live Webmentions Without SSR: A Hybrid Approach
The first version of the WebMentions component fetched from webmention.io at build time and baked the results into the static HTML. It worked, but had one obvious limitation: new interactions only appeared after the next deploy.
Static sites deploy on content changes. A like on Bluesky triggers Bridgy, which sends a webmention to webmention.io. But that webmention sits in the API until the next rebuild. On a site that deploys once a day, a like from this morning might not appear until tomorrow.
Why not SSR?
Server-side rendering would solve the staleness problem — the page fetches fresh data on every request. But switching the whole site to SSR for one component is a significant trade-off: cold start latency on every page load, a server process to maintain, and the loss of static delivery from a CDN edge.
The webmention component is the only dynamic element on otherwise fully static pages. SSR feels like the wrong tool for this.
The hybrid approach
The solution keeps the static build but adds a silent client-side refresh:
- Build time: the component fetches webmention.io and renders complete HTML. Visitors see webmentions immediately — no loading state, no flash of empty content.
- Client side: after the page loads, a small script re-fetches the API. If the mention count changed since the build, it updates the DOM. If nothing changed, it does nothing.
The client-side update is silent — no spinner, no visible transition. The static render is always correct at build time. The client fetch catches anything newer.
Implementation
The Astro frontmatter handles the build-time fetch:
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
}
If the fetch fails (network unavailable, API down), the component renders as empty and the client-side script takes over.
When there are zero mentions at build time, the component renders a hidden placeholder section:
{mentions.length === 0 && (
<section class="webmentions" data-url={url} data-empty></section>
)}
The data-empty attribute triggers display: none in CSS. If the client-side fetch later finds mentions, it removes data-empty and injects the rendered content.
The client-side refresh script
The script runs once on page load. It only re-renders if 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 set on each mention element by the static render. Counting those elements gives the current display count. If the API returns more (or fewer), the section is re-rendered.
The check avoids unnecessary DOM updates. On most page loads, the count matches and nothing happens.
The CSS scoping problem
Astro scopes component styles by adding a data-astro-cid-* attribute to every element in the template and restricting the generated CSS selectors to that attribute. This works for static markup — but when the client-side script writes HTML via innerHTML, the new elements don’t have the scoped attribute. The styles don’t apply.
The fix is <style is:global>:
<style is:global>
.webmentions { ... }
.webmentions__avatar { ... }
</style>
Global styles use plain class selectors without the scoped attribute. They apply to both the static render and the dynamically injected content. The trade-off is that these styles are unscoped globally — acceptable here since the .webmentions__ naming convention avoids collisions.
Result
- Visitors see webmentions immediately on page load (no loading state)
- New interactions appear without a redeploy, as soon as Bridgy polls and sends the webmention
- No SSR overhead — the page is still fully static HTML from a CDN
- The client-side script is small and runs once; it’s invisible unless the count changes