The OG card factory: 17 share-cards, 1 Vercel function
Vercel's Hobby plan caps you at 12 serverless functions. We ship 17
different dynamic Open Graph cards. Here's how we render every
single one out of /api/og.ts — query-dispatched, hand-rolled SVG,
under 50ms cold-start.
The constraint
Vercel Hobby gives you 12 serverless functions. We had a
simple-feeling design at the start: one function per card type.
/api/og/beat.ts, /api/og/wrapped.ts, etc. Eight cards in,
we hit the wall — every new feature meant a hard choice between
"ship the share card" or "ship some other backend endpoint". And
most of the obvious shapes (RevenueCat webhook, daily-digest
cron, redirect tracker, AI search) were not negotiable.
So we collapsed everything into one function and dispatched on a query parameter. Every share card now lives in one file.
// /api/og.ts — cold-path dispatcher
const type = String(req.query.type || 'default');
if (type === 'beat') return res.send(beatCard({ ... }));
if (type === 'producer') return res.send(producerCard({ ... }));
if (type === 'wrapped') return res.send(wrappedCard({ ... }));
if (type === 'tarot') return res.send(tarotCard({ ... }));
if (type === 'streak') return res.send(streakCard({ ... }));
if (type === 'leaderboard') return res.send(leaderboardCard(...));
// ... 11 more
Why hand-rolled SVG, not @vercel/og
@vercel/og is the obvious tool — JSX in, PNG out. It works.
But it ships a Satori-based renderer plus a font subset, and the
cold-path budget ballooned past 200ms on the cards that touch
Supabase before render. For our use-case (social-unfurl preview,
not pixel-perfect typography), a hand-rolled SVG with
<foreignObject> for HTML-style text wrap was 4× faster
and gave us byte-level control over the markup.
The pattern we landed on: each card is a function that returns a
string. Inputs are trusted-only. We escape every
user-supplied value through a tiny escapeXml() helper before
it touches the SVG, and we clip strings to known safe lengths
with clip(text, n) so the layout never overflows.
function clip(s: string, n: number): string {
s = (s || '').trim();
return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…';
}
function escapeXml(s: string): string {
return String(s ?? '').replace(/[<>&"']/g, (c) => ({
'<':'<', '>':'>', '&':'&',
'"':'"', "'":'''
}[c]));
}
Live data without breaking cold-start
Six of the 17 cards are live — they hit Supabase before
rendering: trending, leaderboard, community, feed snapshot,
producer spotlight, and the dynamic beat thumbnail. The trick is
Promise.all + tight indexes. The community card pulls three
separate queries in parallel:
const [savesRes, spotlightRes, trendingRes] = await Promise.all([
supabaseAdmin.from('saved_beats')
.select('user_id', { count: 'exact', head: false })
.gte('created_at', todayIso).limit(2000),
supabaseAdmin.rpc('producer_spotlight_weekly'),
supabaseAdmin.rpc('trending_now', { p_window_minutes: 60, p_limit: 1 }),
]);
Edge cache is set to s-maxage=86400 (24h) for static cards,
s-maxage=300 (5min) for live cards. So the 50ms cold-start
mostly only happens on the first request after a deploy or a
cache eviction.
Composition, not duplication
The 17 cards share a small set of helpers:
logoMark(x, y, size) drops the StudioMode wordmark in any
position, medalColor(rank) returns gold/silver/bronze for
ranked-list cards, gradient definitions are literal-string
constants. The result: each specific card function is
30-80 lines of layout-specific SVG, the rest comes from shared
primitives.
The boring infra discipline you adopt under tight constraints compounds into freedom later.
Spot-checking 17 cards at once
You can't eyeball 17 cards across multiple deploys without a
tool. We built one — /og-preview is an
internal noindex page that loads every card type as a
live <img src="/api/og?type=…"> tile. Touching the
cards file? Ship to staging, open the preview page, scroll. If
anything looks broken it's instant.
The lesson
Constraints make better architecture. The 12-function cap forced consolidation, and the consolidation forced us to design the dispatcher cleanly. Now adding a new card type is a 30-line PR: one function, one dispatch case, one entry in the preview gallery. We've added five new cards in the last two weeks without thinking about the function budget once.
If you're starting an OG card system: skip the per-card-file design and start with the dispatcher. The boring infra discipline you adopt under tight constraints compounds into freedom later.
See the cards live
Every card type rendered in one place — including the new Producer Leaderboard.
Open /og-preview →