Original post: Adding emoji reactions to your Astro blog with Netlify Blobs
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
Diagram fallback for Dev.to. View the canonical article for the full version: https://sourcier.uk/blog/reactions-netlify-blobs-astro
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 token
Install the package:
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):
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_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 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"]) {
// 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>
Top comments (0)