Engineering · April 30, 2026 · 6 min read

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.

17
Card types
1
Function
12
Hobby cap
~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) => ({
    '<':'&lt;', '>':'&gt;', '&':'&amp;',
    '"':'&quot;', "'":'&apos;'
  }[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 →
© 2026 StudioMode · Built with constraints, not in spite of them.