One-off tip

Support checkout

  1. 1Choose amount
  2. 2Payment
  3. 3Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Adding emoji reactions to your Astro blog with Netlify Blobs
Cover image: a group of smiley face balloons on a white background
astronetlifyengineeringfrontend

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

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:

Terminal window
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 token

Install the package:

Terminal window
pnpm add @netlify/blobs

The serverless function

The function lives at netlify/functions/reactions.ts and handles GET (fetch counts), POST (record a reaction), and OPTIONS (CORS preflight):

netlify/functions/reactions.ts
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 traversal
const 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_RE regex is the security boundary: it ensures postId can only be an alphanumeric slug, preventing any path traversal or injection into the store key.
  • The CORS headers and the OPTIONS branch exist because the reactions widget can be embedded and fetched from more than one origin during local development. SITE_URL narrows the allowed origin in production.
  • In Netlify’s Lambda compatibility mode, connectLambda(event) has to run before getStore. Without it, the Blobs client has no runtime context and throws the missing siteID, token error.
  • The explicit NETLIFY_SITE_ID plus token fallback keeps the function usable in local scripts and any path where Netlify hasn’t injected that context.
  • The POST handler wraps JSON.parse in a try/catch so a malformed request body returns a clean 400 instead of an unhandled exception.
  • store.get returns null if 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. The hero variant 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

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
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

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.