Adding emoji reactions to your Astro blog with Netlify Blobs
Serverless key-value storage, a Netlify Function API, and an optimistic UI that feels instant
- Published
- 17 September 2026
- Read time
- 14 min read
Was this useful?
Comments ask a lot of a reader. Reactions ask almost nothing, a single click and a second of intent. That asymmetry is what makes them worth adding: most readers who found your post useful will never write a comment, but many of them will click a heart or a lightbulb if you make it easy enough.
This post covers the full implementation on this blog: a Netlify Blobs store for persistence, a serverless function for the API, and an Astro component that handles optimistic updates, localStorage deduplication, and a pop animation that makes the whole thing feel alive.
Architecture overview
The client side is purely progressive: if the function call fails, the optimistic
increment still shows. On the next page load, fetchCounts corrects it.
Setting up Netlify Blobs
Netlify Blobs is a managed key-value store included on all Netlify plans, no
database to provision, no connection strings to manage. When deployed, it works
with zero configuration. For local development, the explicit fallback is
NETLIFY_SITE_ID plus either NETLIFY_AUTH_TOKEN or NETLIFY_PAT
in .env:
NETLIFY_PAT= # personal access token β use NETLIFY_PAT, not NETLIFY_ACCESS_TOKEN # (Netlify auto-injects NETLIFY_ACCESS_TOKEN at runtime with a limited # site-scoped machine token, which overwrites any value you set)NETLIFY_SITE_ID= # visible in Site configuration β General β Site ID# Optional: NETLIFY_AUTH_TOKEN= # use this only if you want a separate Blobs tokenInstall the package:
pnpm add @netlify/blobsThe serverless function
The function lives at netlify/functions/reactions.ts and handles GET (fetch
counts), POST (record a reaction), and OPTIONS (CORS preflight):
import type { HandlerEvent } from "@netlify/functions";import { connectLambda, getStore } from "@netlify/blobs";
const REACTIONS = ["heart", "fire", "bulb", "clap"] as const;// Matches slugs like "deploying-astro-netlify" β prevents path traversalconst SLUG_RE = /^[a-z0-9-]+$/;
const CORS = { "Access-Control-Allow-Origin": process.env.SITE_URL?.replace(/\/$/, "") ?? "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type",};
function getReactionsStore(event: HandlerEvent & { blobs?: string }) { if (event.blobs && event.headers?.["x-nf-site-id"]) { connectLambda(event as any); return getStore("reactions"); }
const siteID = process.env.NETLIFY_SITE_ID; const token = process.env.NETLIFY_AUTH_TOKEN ?? process.env.NETLIFY_PAT;
if (siteID && token) { return getStore("reactions", { siteID, token }); }
return getStore("reactions");}
export const handler = async (event: HandlerEvent & { blobs?: string }) => { if (event.httpMethod === "OPTIONS") { return { statusCode: 204, headers: CORS, body: "" }; }
const postId = event.queryStringParameters?.post ?? ""; if (!postId || !SLUG_RE.test(postId)) { return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "Invalid post ID" }), }; }
const store = getReactionsStore(event);
if (event.httpMethod === "GET") { const data = (await store.get(postId, { type: "json" })) ?? {}; return { statusCode: 200, headers: { ...CORS, "Content-Type": "application/json" }, body: JSON.stringify(data), }; }
if (event.httpMethod === "POST") { let body: { reaction?: unknown }; try { body = JSON.parse(event.body ?? "{}"); } catch { return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "Invalid JSON" }), }; }
const reaction = body.reaction; if (!REACTIONS.includes(reaction as (typeof REACTIONS)[number])) { return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "Invalid reaction" }), }; }
const data: Record<string, number> = (await store.get(postId, { type: "json" })) ?? {}; data[reaction as string] = (data[reaction as string] ?? 0) + 1; await store.set(postId, JSON.stringify(data));
return { statusCode: 200, headers: { ...CORS, "Content-Type": "application/json" }, body: JSON.stringify(data), }; }
return { statusCode: 405, headers: CORS, body: "Method not allowed" };};A few things worth noting:
- The
SLUG_REregex is the security boundary: it ensurespostIdcan only be an alphanumeric slug, preventing any path traversal or injection into the store key. - The
CORSheaders and theOPTIONSbranch exist because the reactions widget can be embedded and fetched from more than one origin during local development.SITE_URLnarrows the allowed origin in production. - In Netlifyβs Lambda compatibility mode,
connectLambda(event)has to run beforegetStore. Without it, the Blobs client has no runtime context and throws the missingsiteID, tokenerror. - The explicit
NETLIFY_SITE_IDplus token fallback keeps the function usable in local scripts and any path where Netlify hasnβt injected that context. - The POST handler wraps
JSON.parsein a try/catch so a malformed request body returns a clean 400 instead of an unhandled exception. store.getreturnsnullif the key doesnβt exist, so?? {}handles the cold-start case cleanly.- The function returns the full updated counts on POST so the client can sync without a second GET, one round trip per reaction.
The Astro component
The component has three responsibilities: load counts on mount, post reactions on click, and preserve which reactions the current user has already given.
Preventing duplicate votes
Thereβs no authentication on this blog, so duplicate prevention is localStorage-based.
Each post gets a key of reactions:<slug> holding a JSON array of reaction keys
the user has already clicked. On mount, this set is read back and used to restore
the active button state.
This means:
- Clearing localStorage clears the deduplication state, by design.
- It doesnβt prevent someone from opening a private window. Thatβs an acceptable tradeoff for a zero-auth reaction system.
- It does prevent the common case: a page refresh or return visit triggering accidental double-counting.
Optimistic updates
When a button is clicked, the count increments immediately in the UI before the API call returns. This makes the interaction feel instant. Once the API responds, the displayed counts are replaced with the server truth, which will be identical in the normal case and correct in the rare case of a race condition.
async function postReaction(btn: HTMLButtonElement) { const reaction = btn.dataset.reaction!; if (reacted.has(reaction)) return; // already voted
reacted.add(reaction); saveReacted(); // persist to localStorage immediately
// Optimistic increment β no waiting for the network const countEl = btn.querySelector<HTMLElement>(".reactions__count")!; const current = parseInt(countEl.textContent ?? "0", 10) || 0; countEl.textContent = String(current + 1);
const res = await fetch(`/.netlify/functions/reactions?post=${postId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reaction }), }); if (res.ok) { const data = await res.json(); // Sync all buttons with server truth root.querySelectorAll<HTMLButtonElement>(".reactions__btn").forEach((b) => { updateButton(b, data[b.dataset.reaction!] ?? 0); }); }}The pop animation
A small @keyframes animation triggers on click for tactile feedback:
@keyframes btn-pop { 0% { transform: scale(1); } 40% { transform: scale(1.22); } 70% { transform: scale(0.94); } 100% { transform: scale(1); }}The class is added, a reflow is forced with void btn.offsetWidth, then the class
is re-added, this restarts the animation even if the same button is clicked twice
quickly. prefers-reduced-motion is respected by removing the transition on the
emoji element.
Placement: hero and page menu, not a standalone block
The Reactions component takes variant and compact props so the same
markup and script can adapt to two very different contexts without duplicating
logic:
- Post hero.
<Reactions postId={postId} variant="hero" compact />sits in the engagement row directly under the title, next to the share button. Theherovariant visually hides the heading and shrinks the buttons into a row of compact pills, so reacting is the first thing a reader can do, before theyβve committed to reading the whole article. - Page menu.
<Reactions postId={postId} compact />sits inside the floating page menu panel, alongside the table of contents, share, and support sections. This keeps reacting reachable from anywhere while scrolling, without competing for space with the article body.
Both placements pass compact, which switches the buttons to a horizontal
pill layout and hides the text label, leaving only the emoji and count. The
hero variant additionally visually hides the heading for screen-reader-only
context, since the surrounding UI already makes the purpose clear.
This spreads the interaction across two low-friction entry points instead of funnelling it through a single comments-adjacent widget.
What the counts look like when empty
On first load before any reactions, counts display β rather than 0. This is a
deliberate choice: 0 signals βnobody found this usefulβ, which is discouraging
for a new post. A dash is neutral and avoids that framing.
Local development
With connectLambda(event) in place, netlify dev injects the Blobs context
for Lambda compatibility functions and runs the store in local sandbox mode.
If that context isnβt available, the NETLIFY_SITE_ID plus token fallback still
lets the function connect manually.
That keeps normal local development isolated from production while still making the function portable outside Netlifyβs request pipeline.
Full code listing
// Stores and retrieves emoji reaction counts for blog posts using Netlify Blobs.//// GET /.netlify/functions/reactions?post=<slug> β { heart: N, fire: N, bulb: N, clap: N }// POST /.netlify/functions/reactions?post=<slug> body: { reaction: "heart" } β updated counts//// Local dev: requires NETLIFY_SITE_ID plus either NETLIFY_AUTH_TOKEN or// NETLIFY_PAT in .env// Production: works automatically with no extra config
import type { HandlerEvent } from "@netlify/functions";import { connectLambda, getStore } from "@netlify/blobs";
const REACTIONS = ["heart", "fire", "bulb", "clap"] as const;// Matches slugs like "deploying-astro-netlify" β prevents path traversalconst SLUG_RE = /^[a-z0-9-]+$/;
const CORS = { "Access-Control-Allow-Origin": process.env.SITE_URL?.replace(/\/$/, "") ?? "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type",};
function getReactionsStore(event: HandlerEvent & { blobs?: string }) { if (event.blobs && event.headers?.["x-nf-site-id"]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any connectLambda(event as any); return getStore("reactions"); }
const siteID = process.env.NETLIFY_SITE_ID; const token = process.env.NETLIFY_AUTH_TOKEN ?? process.env.NETLIFY_PAT;
if (siteID && token) { return getStore("reactions", { siteID, token }); }
return getStore("reactions");}
export const handler = async (event: HandlerEvent & { blobs?: string }) => { if (event.httpMethod === "OPTIONS") { return { statusCode: 204, headers: CORS, body: "" }; }
const postId = event.queryStringParameters?.post ?? ""; if (!postId || !SLUG_RE.test(postId)) { return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "Invalid post ID" }), }; }
const store = getReactionsStore(event);
if (event.httpMethod === "GET") { const data = (await store.get(postId, { type: "json" })) ?? {}; return { statusCode: 200, headers: { ...CORS, "Content-Type": "application/json" }, body: JSON.stringify(data), }; }
if (event.httpMethod === "POST") { let body: { reaction?: unknown }; try { body = JSON.parse(event.body ?? "{}"); } catch { return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "Invalid JSON" }), }; }
const reaction = body.reaction; if (!REACTIONS.includes(reaction as (typeof REACTIONS)[number])) { return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "Invalid reaction" }), }; }
const data: Record<string, number> = (await store.get(postId, { type: "json" })) ?? {}; data[reaction as string] = (data[reaction as string] ?? 0) + 1; await store.set(postId, JSON.stringify(data));
return { statusCode: 200, headers: { ...CORS, "Content-Type": "application/json" }, body: JSON.stringify(data), }; }
return { statusCode: 405, headers: CORS, body: "Method not allowed" };};---interface Props { postId: string; variant?: "default" | "hero"; compact?: boolean;}const { postId, variant = "default", compact = false } = Astro.props;const heading = variant === "hero" ? "Was this useful?" : "Did you find this useful?";
const reactions = [ { key: "heart", emoji: "β€οΈ", label: "Love it" }, { key: "fire", emoji: "π₯", label: "On fire" }, { key: "bulb", emoji: "π‘", label: "Insightful" }, { key: "clap", emoji: "π", label: "Helpful" },] satisfies { key: string; emoji: string; label: string }[];---
<div class:list={[ "reactions", { "reactions--hero": variant === "hero", "reactions--compact": compact, }, ]} data-post-id={postId} aria-label="Post reactions"> <p class="reactions__heading">{heading}</p> <div class="reactions__buttons" role="group" aria-label="React to this post"> { reactions.map((r) => ( <button class="reactions__btn" data-reaction={r.key} aria-label={r.label} aria-pressed="false" type="button" > <span class="reactions__emoji" aria-hidden="true"> {r.emoji} </span> <span class="reactions__count" aria-live="polite"> β </span> <span class="reactions__label">{r.label}</span> </button> )) } </div></div>
<script> document.querySelectorAll<HTMLElement>(".reactions").forEach((root) => { if (root.dataset.bound === "true") return; root.dataset.bound = "true";
const postId = root.dataset.postId; if (!postId) return; const storageKey = `reactions:${postId}`;
function loadReacted(): string[] { try { const stored = JSON.parse(localStorage.getItem(storageKey) ?? "[]"); return Array.isArray(stored) ? stored.filter((value): value is string => typeof value === "string") : []; } catch { return []; } }
const reacted = new Set<string>(loadReacted());
function saveReacted() { localStorage.setItem(storageKey, JSON.stringify([...reacted])); }
function updateButton(btn: HTMLButtonElement, count: number) { const reaction = btn.dataset.reaction; if (!reaction) return; const countEl = btn.querySelector<HTMLElement>(".reactions__count")!; countEl.textContent = count > 0 ? String(count) : "β"; const hasReacted = reacted.has(reaction); btn.classList.toggle("reactions__btn--active", hasReacted); btn.setAttribute("aria-pressed", hasReacted ? "true" : "false"); }
// Load current counts from the API async function fetchCounts() { try { const res = await fetch(`/.netlify/functions/reactions?post=${postId}`); if (!res.ok) return; const data: Record<string, number> = await res.json(); root .querySelectorAll<HTMLButtonElement>(".reactions__btn") .forEach((btn) => { updateButton(btn, data[btn.dataset.reaction!] ?? 0); }); } catch {} }
// Post a reaction async function postReaction(btn: HTMLButtonElement) { const reaction = btn.dataset.reaction!; if (reacted.has(reaction)) return;
reacted.add(reaction); saveReacted(); btn.classList.add("reactions__btn--active"); btn.setAttribute("aria-pressed", "true"); btn.classList.remove("reactions__btn--pop"); void btn.offsetWidth; // force reflow to restart animation btn.classList.add("reactions__btn--pop");
// Optimistic increment const countEl = btn.querySelector<HTMLElement>(".reactions__count")!; const current = parseInt(countEl.textContent ?? "0", 10) || 0; countEl.textContent = String(current + 1);
try { const res = await fetch( `/.netlify/functions/reactions?post=${postId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reaction }), }, ); if (res.ok) { const data: Record<string, number> = await res.json(); // Sync with server truth root .querySelectorAll<HTMLButtonElement>(".reactions__btn") .forEach((b) => { updateButton(b, data[b.dataset.reaction!] ?? 0); }); } } catch {} }
fetchCounts();
root .querySelectorAll<HTMLButtonElement>(".reactions__btn") .forEach((btn) => { btn.addEventListener("click", () => postReaction(btn)); }); });</script>
<style lang="scss"> @keyframes btn-pop { 0% { transform: scale(1); } 40% { transform: scale(1.22); } 70% { transform: scale(0.94); } 100% { transform: scale(1); } }
.reactions { margin-bottom: 2.5rem; }
.reactions--hero { margin: 0; }
.reactions--compact { margin: 0; }
.reactions__heading { font-family: "Barlow Condensed", sans-serif; font-size: 1.25rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-primary); margin: 0 0 1.25rem; }
.reactions__buttons { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.reactions__btn { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 5rem; padding: 1rem 1.25rem; border: 2px solid var(--border-subtle); border-radius: var(--radius-compact); background: transparent; cursor: pointer; transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
@media (max-width: 639px) { min-width: 4.25rem; padding: 0.875rem 1rem; }
&:hover { border-color: var(--accent-primary); background-color: rgba(var(--accent-primary-rgb), 0.04); transform: translateY(-2px);
.reactions__emoji { transform: scale(1.15); } }
&--active { border-color: var(--accent-primary); background-color: rgba(var(--accent-primary-rgb), 0.08);
.reactions__count { color: var(--accent-primary); } .reactions__label { color: var(--accent-primary); } }
&--pop { animation: btn-pop 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; } }
.reactions__emoji { font-size: 1.75rem; line-height: 1; transition: transform 0.15s ease;
@media (prefers-reduced-motion: reduce) { transition: none; } }
.reactions__count { font-family: "Barlow Condensed", sans-serif; font-size: 1.25rem; font-weight: 800; line-height: 1; color: var(--text-primary); transition: color 0.15s ease; }
.reactions__label { font-family: "Barlow Condensed", sans-serif; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); transition: color 0.15s ease; }
.reactions--compact .reactions__heading { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.reactions--compact .reactions__buttons { gap: 0.45rem; justify-content: flex-end; }
.reactions--compact .reactions__btn { flex-direction: row; justify-content: center; min-width: auto; gap: 0.35rem; padding: 0.5rem 0.65rem; border-width: 1px; border-radius: var(--radius-pill); border-color: color-mix( in srgb, rgba(var(--overlay-rgb), 0.16) 35%, var(--border-subtle) ); background: color-mix( in srgb, var(--surface-elevated) 76%, rgba(var(--overlay-rgb), 0.08) ); backdrop-filter: blur(8px); }
.reactions--compact .reactions__emoji { font-size: 1rem; }
.reactions--compact .reactions__count { min-width: 1ch; font-size: 0.95rem; }
.reactions--compact .reactions__label { display: none; }
@media (max-width: 1023px) { .reactions--compact.reactions--hero .reactions__heading { position: static; width: auto; height: auto; padding: 0; margin: 0 0 0.75rem; overflow: visible; clip: auto; white-space: normal; border: 0; font-size: 0.76rem; letter-spacing: 0.1em; color: color-mix(in srgb, var(--text-primary) 72%, transparent); }
.reactions--compact.reactions--hero .reactions__buttons { justify-content: flex-start; gap: 0.5rem; } }
.reactions--hero .reactions__heading { margin-bottom: 0.75rem; font-size: 0.76rem; letter-spacing: 0.1em; color: color-mix(in srgb, var(--text-primary) 72%, transparent); }
.reactions--hero .reactions__buttons { gap: 0.5rem; }
.reactions--hero .reactions__btn { flex-direction: row; justify-content: center; min-width: auto; gap: 0.45rem; padding: 0.55rem 0.8rem; border-width: 1px; border-radius: var(--radius-pill); border-color: color-mix( in srgb, rgba(var(--overlay-rgb), 0.16) 35%, var(--border-subtle) ); background: color-mix( in srgb, var(--surface-elevated) 76%, rgba(var(--overlay-rgb), 0.08) ); backdrop-filter: blur(8px); }
.reactions--hero .reactions__emoji { font-size: 1.1rem; }
.reactions--hero .reactions__count { min-width: 1ch; font-size: 1rem; }
.reactions--hero .reactions__label { display: none; }
@media (max-width: 639px) { .reactions--hero .reactions__btn { padding: 0.55rem 0.75rem; } }</style>Working on something similar?
Need help raising the bar?
I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.
- Engineering practice review
- Hands-on delivery
- Team mentoring
If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.
Free Β· Practical Β· One email per post
Get practical engineering notes
One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.

Comments
Loading commentsβ¦
Leave a comment