DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on Originally published at sourcier.uk

Adding emoji reactions to your Astro blog with Netlify Blobs

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

Mermaid diagram

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
Enter fullscreen mode Exit fullscreen mode

Install the package:

pnpm add @netlify/blobs
Enter fullscreen mode Exit fullscreen mode

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" };
};
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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); }
}
Enter fullscreen mode Exit fullscreen mode

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

// 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" };
};
Enter fullscreen mode Exit fullscreen mode
---
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>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)