Joan Comadran
Field notes

Engineering

Shipping shareable poll results with Next.js ImageResponse

How I generate a 1080×1350 vote-intention PNG with next/og, wire the Web Share API on mobile with a download fallback, and keep every share free of the viewer's vote — including during Spain's electoral blackout.

October 7, 20267 min read

I've been building pollitics.es: a Spanish citizen thermometer for vote intention, wellbeing, and political mapping. The product lives or dies on whether people actually spread the aggregate. Screenshots of a responsive layout crop wrong, leak UI chrome, and — worse — sometimes include someone's personal ballot if they voted before sharing. So last week I shipped a dedicated shareable results image: one PNG, same for everyone, generated with next/og, handed to the native share sheet on mobile.

This is the version that survived the quirks. Not the happy-path demo.

Why a route beats "just screenshot it"

Open Graph meta tags (og:image) are for link previews when someone pastes a URL. That is not the same product as "tap Share and attach a chart to WhatsApp / Instagram Stories / X". Meta cards are landscape-ish, often cached for days, and you don't control the crop once the client app decides to be clever. Instagram and WhatsApp Stories want a tall frame. Voters want a button that feels like sharing a result, not sharing a browser tab.

So the shape is two pieces:

  1. A GET route that returns a PNG built with ImageResponse from next/og.
  2. A client button that fetches that PNG as a File, then either navigator.share({ files }) or falls back to a download.

The URL on pollitics is /intencion-de-voto/imagen. Revalidate every 15 minutes — same cadence as the public results ISR — so the image and the page don't disagree for long.

The image: public aggregate only

The hard product rule: the image never carries the viewer's vote. It is the latest public snapshot, identical for every downloader. No "you voted X" badge, no personalised colour flash, no N in the corner that would let someone reverse-engineer a tiny sample. We even strip the "Basado en N respuestas" line and the snapshot date from the PNG (decided 2026-10-04) so a viral share doesn't become a forensic artefact.

Dimensions are 1080 × 1350 — the vertical that Instagram, X, and WhatsApp leave alone without cropping the bars. Monochrome chrome, party colours only on the bars, Inter subset for Latin glyphs. If there aren't enough votes yet, or Spain's electoral blackout is on, the route still returns an image: it just shows a freeze message instead of percentages. Diffusing polls during the LOREG blackout is illegal; the share UI is hidden on the page in that window, and the image route refuses to paint numbers even if someone hits the URL directly.

Rough shape of the route:

tsx
// app/intencion-de-voto/imagen/route.tsx
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";

export const revalidate = 900;

const WIDTH = 1080;
const HEIGHT = 1350;

export async function GET() {
  const results = await getPublicResults(/* supabase public client */);
  const blackout = results?.blackoutActive;
  const ready = /* snapshot exists && votesToPublish(results) === 0 */;

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "space-between",
          background: "#ffffff",
          padding: 72,
          fontFamily: "Inter",
        }}
      >
        <div style={{ display: "flex", flexDirection: "column" }}>
          <div style={{ fontSize: 28, color: "#595959" }}>
            Intención de voto · Elecciones generales
          </div>
          <div style={{ fontSize: 64, lineHeight: 1.08, marginTop: 16 }}>
            Si mañana hubiera elecciones
          </div>
        </div>

        {ready && !blackout ? (
          /* bar rows from snapshot.decided — acronym, colour, pct */
          <Bars rows={toRows(results.snapshot.decided)} />
        ) : (
          <div style={{ display: "flex", fontSize: 44, lineHeight: 1.2 }}>
            {blackout
              ? "Periodo electoral: resultados congelados."
              : "Todavía no hay votos suficientes para publicar resultados."}
          </div>
        )}

        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            fontSize: 30,
            borderTop: "2px solid #000",
            paddingTop: 24,
          }}
        >
          <span>¿Y tú qué votarías?</span>
          <span>pollitics.es</span>
        </div>
      </div>
    ),
    {
      width: WIDTH,
      height: HEIGHT,
      fonts: [
        {
          name: "Inter",
          data: await readFile(join(process.cwd(), "assets/fonts/Inter-Regular.ttf")),
          weight: 400,
        },
      ],
    },
  );
}

Two details that matter in production:

  • Load the font from disk, not from a CDN fetch inside the handler. Cold starts and font CDNs love to race each other; bundling Inter under assets/fonts made the route boringly reliable.
  • Cap the rows. Spanish ballots are long. We show at most 12 bars and fold the rest into "Otros", otherwise the type collapses and Stories crop the bottom parties.

The button: Safari will punish you

navigator.share({ files: [png] }) is the right API on mobile. Desktop browsers often don't support file shares, so you need a download path. The nasty part is Safari's user-gesture requirement: if you await fetch(image) inside the click handler before calling share(), Safari decides the gesture expired and throws NotAllowedError.

The fix is to start the fetch on pointerdown / focus, stash the Promise<File>, and resolve it on click:

tsx
"use client";

const IMAGE_URL = "/intencion-de-voto/imagen";

export function ShareImage() {
  const pending = useRef<Promise<File> | null>(null);
  const fileName = `pollitics-intencion-de-voto-${new Date()
    .toISOString()
    .slice(0, 10)}.png`;

  function prepare() {
    pending.current ??= fetch(IMAGE_URL)
      .then(async (res) => {
        if (!res.ok) throw new Error(String(res.status));
        return new File([await res.blob()], fileName, { type: "image/png" });
      })
      .catch((e) => {
        pending.current = null;
        throw e;
      });
    return pending.current;
  }

  function download(file: File) {
    const url = URL.createObjectURL(file);
    Object.assign(document.createElement("a"), {
      href: url,
      download: fileName,
    }).click();
    URL.revokeObjectURL(url);
  }

  async function share() {
    let file: File | null = null;
    try {
      file = await prepare();
      if (!navigator.canShare?.({ files: [file] })) return download(file);
      await navigator.share({
        files: [file],
        text: "Intención de voto en pollitics.es. ¿Y tú qué votarías?",
      });
    } catch (e) {
      const name = e instanceof DOMException ? e.name : "";
      if (name === "NotAllowedError" && file) download(file);
      // AbortError = user cancelled the sheet. Leave it alone.
    }
  }

  return (
    <button
      type="button"
      onPointerDown={() => prepare().catch(() => {})}
      onFocus={() => prepare().catch(() => {})}
      onClick={share}
    >
      Compartir imagen
    </button>
  );
}

canShare({ files }) is the gate. Don't call share with files if it returns false — some browsers claim navigator.share exists but reject file payloads. And always treat AbortError as success-shaped: the user closed the sheet on purpose.

On the page, the component is gated with {!results.blackoutActive && <ShareImage />}. Legal constraint first, UX second.

Don't publish noise: the 50-vote floor

The first version of pollitics showed percentages from vote one. That looked clever in staging and looked broken in production: a 100% bar for whoever voted first is not a result, it's a bug wearing a chart. On 2026-10-06 we raised the public floor to 50 votes in the rolling 90-day count (MIN_PUBLIC_SAMPLE). Below that, the UI says how many votes are still missing; the image route shows the same "not enough yet" copy instead of bars.

ts
export const MIN_PUBLIC_SAMPLE = 50;

export function votesToPublish(results: PublicResults | null) {
  return Math.max(
    0,
    MIN_PUBLIC_SAMPLE - (results?.snapshot?.meta.sample_total ?? 0),
  );
}

After 50, we still label anything under 1.000 as "Datos preliminares". We never invent a margin of error — this is open participation, not a representative poll, and the image footer says so in plain Spanish.

What I'd steal for any product, not just polls

If you're shipping a shareable artefact from a Next.js app, the checklist that actually moved the needle for me:

ConcernChoiceWhy
RenderImageResponse / next/ogSame deploy as the app; no Puppeteer farm
Size1080×1350 (or your Stories frame)Platforms stop fighting you
Cacherevalidate aligned with page ISRImage and page stay honest together
PrivacyOne public payload, zero personalisationScreenshots of authenticated UI will betray you
Mobile sharePrefetch file on pointerdown, then canShareSafari's gesture clock is not optional
FallbackObject-URL downloadDesktop and stubborn WebViews
Legal / policyHide the button and blank the numbersDeep links exist

The whole feature is maybe two hundred lines. The product decisions around it — blackout, minimum sample, never stamp N on the PNG — took longer than the code, which is usually the tell that you built the right thing.

If you want the live version, vote (or just look) on pollitics.es/intencion-de-voto once the sample clears fifty. The share button is under the results. Your vote stays off the image. That was the entire point.

Keep reading

03