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:
- A GET route that returns a PNG built with
ImageResponsefromnext/og. - A client button that fetches that PNG as a
File, then eithernavigator.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:
// 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/fontsmade 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:
"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.
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:
| Concern | Choice | Why |
|---|---|---|
| Render | ImageResponse / next/og | Same deploy as the app; no Puppeteer farm |
| Size | 1080×1350 (or your Stories frame) | Platforms stop fighting you |
| Cache | revalidate aligned with page ISR | Image and page stay honest together |
| Privacy | One public payload, zero personalisation | Screenshots of authenticated UI will betray you |
| Mobile share | Prefetch file on pointerdown, then canShare | Safari's gesture clock is not optional |
| Fallback | Object-URL download | Desktop and stubborn WebViews |
| Legal / policy | Hide the button and blank the numbers | Deep 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.

