Next.js Static Architecture Web Performance

OpenGraph Images at Build Time: Why Static Sites Win Social Cards

Static Signal
A vast brass darkroom filled with framed glowing rectangles being stamped into existence by clockwork arms, neon copper light pouring through translucent stencils, dark steampunk image foundry

Open the network tab on almost any modern blog and try to load the social card image directly. Most of the time you’ll see a request to something like /api/og?title=... that takes 600 milliseconds, returns a PNG, and is being generated by a serverless function on every cold start.

This is everywhere now. Vercel popularized it. Every framework has a guide for it. The DX is great — you write a React component that “renders” an image, you slap a tag in your <head>, and you’ve got a beautiful per-page social card with no design tool involved.

It’s also slow, expensive, fragile, and entirely unnecessary if your site is static.

The dirty secret of runtime OG image generation is that the function gets called constantly — by Slack unfurls, by LinkedIn’s preview crawler, by iMessage when someone pastes a link, by every single bot that scans the web for link previews — and most of those calls aren’t being cached the way you think they are. You’re paying for a perpetually-warm function whose output never actually changes for a given URL. That output should be a file. On a CDN. Generated once.

Static sites can do exactly that. The build pipeline already runs at deploy time, the framework already has tools to render React components to images, and the result is a directory of PNGs that get served like any other static asset — with a cache lifetime measured in years, at zero compute cost, with no cold start, and with no failure mode beyond “the file doesn’t exist.” If you’re building a content site, this is the model that wins.


What Social Cards Actually Are, and Why They Hit Your Server So Often

OpenGraph metadata is a set of <meta> tags in the page head that tell crawlers how a link should look when previewed. The big ones — og:image, og:title, og:description, plus their Twitter equivalents — are read by every social platform, every messaging app, every link unfurler, every chat tool, and increasingly by AI agents that summarize or quote URLs.

The image is the part that matters most. Text previews are skimmed; images are noticed. A blog post with a custom card showing the title and a tasteful background image gets meaningfully more click-through than one falling back to a generic site logo or — worse — no image at all.

The traffic profile of OG image requests is wild. Every time someone shares your post:

  • Slack fetches the image once per workspace per cache window.
  • iMessage fetches it on every device the message lands on.
  • LinkedIn fetches it on the original post and again on every share.
  • X fetches it through their image proxy, which has its own caching layer.
  • Discord fetches it twice — once for the inline embed, once for any reply that quotes the link.
  • Search engines fetch it as part of indexing.
  • Generative AI crawlers fetch it as part of training data collection.
  • Every link aggregator, every RSS-to-social bridge, every Mastodon instance that federates the post.

A single moderately popular post can generate thousands of OG image fetches in the first 24 hours. If each one is hitting a serverless function with a cold start, you’re paying compute time over and over for the same byte-identical output.

This is a textbook case of “thing that should be a file.”


The Runtime Approach and Why It Spread Anyway

The runtime approach took off because the DX is genuinely fantastic. You write something like:

export default function Image({ params }) {
  return new ImageResponse(
    <div style={{ display: 'flex', background: '#0a0a0a', color: 'white' }}>
      <h1>{params.title}</h1>
    </div>,
    { width: 1200, height: 630 }
  )
}

You get back a PNG. The image looks like the React you wrote. You can pull data from your CMS at request time. You can A/B test card designs. You can read URL parameters and generate cards for arbitrary content that doesn’t exist in your codebase.

The flexibility is real, and for a small set of use cases it’s the right call. If your URLs are open-ended (/og?title=Anything+The+User+Pastes), if you’re generating cards for ephemeral content, if you genuinely need request-time data — runtime makes sense.

For a blog or content site, you don’t need any of that. Your URLs are bounded. The set of pages is known at build time. The post titles, hero images, and metadata are sitting in your repo as files. The card content for /posts/some-slug is the same byte-for-byte output every single time anyone requests it. There is no request-time variable to read. There is no dynamic data to pull. The “function” is a pure mapping from page → image, and pure mappings deserve to be precomputed.


The Costs Hiding in Runtime Generation

Three categories of cost that don’t show up until you have traffic.

Compute billing. OG generation libraries like Satori (under the hood of @vercel/og) are not free. A single render takes 100–400 milliseconds of CPU on a serverless function. Multiply by every share fetch from every platform’s crawler, and a popular post can rack up thousands of function invocations per day. On Vercel’s hobby tier you’ll burn through your function-second budget on a single viral post. On their pro tier, the bill is small but persistent. On the platforms that charge by the millisecond rather than the invocation, the bill scales with how complex your card design is. None of this is necessary if the output is identical every time.

Cold start latency. A cold serverless function takes 800ms to 2.5 seconds to spin up before it can render anything. Slack, LinkedIn, and several AI crawlers have aggressive timeouts on link unfurling. If your function is cold when their crawler hits it, the unfurl silently fails and your post shows up as a bare URL. This happens more than people realize, especially for posts that get shared on platforms whose crawlers don’t pre-warm. The cold start makes runtime generation actively unreliable for the exact use case it’s most often deployed for.

Caching is harder than it looks. “Just put a CDN in front of it” is the usual response. CDNs in front of serverless functions are full of footguns: cache keys that include unexpected query strings, vary headers that fragment the cache by user agent, edge regions that warm independently, invalidation that doesn’t propagate. You can get good caching working, but it’s a project. And every time you redeploy, the cache might or might not invalidate the way you expect, depending on how your platform implements deploys-as-cache-bust. A static file in your build output has none of these problems.

Build-time guarantees that runtime can’t make. With build-time generation, the moment your build succeeds, you know every OG image exists, looks correct, and is the right size. With runtime generation, you find out about errors when a crawler hits a path that throws an exception, and you find out about visual regressions when someone screenshots a Slack unfurl and DMs you about it. Build-time errors fail your CI; runtime errors degrade your SEO and brand presence silently.


What Build-Time Generation Looks Like

The mechanics are simple. At build time, for each piece of content, render an OG image once and write it to your static output directory. The result is public/og/post-slug.png (or similar) for every post. Reference it from the page’s <head>. Done.

The implementation depends on framework, but the core pieces are the same: an image rendering library (Satori, Sharp, or @resvg/resvg-js) and a script in your build pipeline that iterates over your content.

For a Next.js static export, the cleanest pattern uses a generateMetadata export that points to a pre-generated image, plus a separate build step that produces the images themselves:

// scripts/generate-og-images.ts
import { writeFileSync, mkdirSync } from 'node:fs'
import { Resvg } from '@resvg/resvg-js'
import satori from 'satori'
import { getAllPosts } from '../src/lib/content'

async function main() {
  mkdirSync('public/og', { recursive: true })
  const posts = await getAllPosts()

  for (const post of posts) {
    const svg = await satori(template(post), {
      width: 1200,
      height: 630,
      fonts: await loadFonts(),
    })
    const png = new Resvg(svg).render().asPng()
    writeFileSync(`public/og/${post.slug}.png`, png)
  }
}

main()

You wire this into your build script:

{
  "scripts": {
    "build": "tsx scripts/generate-og-images.ts && next build"
  }
}

And reference the output from your post page:

export async function generateMetadata({ params }) {
  return {
    openGraph: {
      images: [`/og/${params.slug}.png`],
    },
  }
}

The build now produces a PNG for every post. They live in public/og/ and get deployed alongside your HTML. The CDN serves them with permanent cache headers. The serverless function is gone. The cold start is gone. The compute bill is gone. The unfurl failures are gone.

The whole thing is a 50-line script and a build hook.


What Build-Time Costs You

Honest tradeoffs:

Build time grows with content. Every post adds a few hundred milliseconds to your build. For a blog with 50 posts, that’s an extra 30 seconds. For one with 5,000 posts, you’re adding several minutes. The fix is to cache by content hash — only regenerate the image if the post frontmatter or template changed. A trivial if (existsSync(path) && contentHash === storedHash) continue keeps incremental builds fast.

You can’t read request-time data. If your card needs to display the current view count or the user’s name, build-time can’t help you. For 99% of content sites, you don’t have either of those needs.

You need to redeploy to update an image. Changed your card design? You have to rebuild and redeploy to regenerate every PNG. This is technically a tradeoff but in practice it’s a feature — you want a redeploy to be the only path to changing what your readers see, because it gives you the same Git-backed audit trail as everything else on a static site.

Storage adds up. A 1200×630 PNG is typically 30–80KB. A site with 5,000 posts ships 150–400MB of OG images. Most static hosts have generous build output limits (Cloudflare Pages: 25,000 files; Vercel: 100MB per file but unlimited count on paid tiers; Netlify: configurable). You should check, but for almost every blog this is a non-issue.

These are real costs, and they’re nothing compared to the runtime alternative.


Designing the Card Itself

The dimensions are 1200×630 for the standard OG image, which both X and LinkedIn crop to a 1.91:1 aspect ratio when displaying. Anything you put within the central horizontal third is safe; the edges may be cropped on some platforms.

Design constraints worth knowing:

  • Type sizes that look fine on desktop disappear on mobile. Slack on a phone shows your card at maybe 280 pixels wide. Anything smaller than 48px in your 1200-wide design will be unreadable. Use big, bold type.
  • High contrast wins. Cards are seen in feeds full of competing imagery. A subdued palette gets ignored. Pick one strong color, one background, and let the title carry weight.
  • Don’t put critical info in corners. Cropping kills it. Center the title.
  • Brand consistency matters more than per-post novelty. A reader who sees three of your cards in a row should immediately recognize the fourth. Pick a template and stick to it. Vary the title and the background image; don’t redesign the layout.

Satori specifically supports a flexbox subset of CSS. You can build the layout with React-style components and a sensible style object. Here’s the kind of template that works:

function template(post) {
  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      width: '100%',
      height: '100%',
      background: '#0a0a0a',
      color: '#f5f5f5',
      padding: 80,
      fontFamily: 'Inter',
    }}>
      <div style={{ fontSize: 32, opacity: 0.6 }}>Static Signal</div>
      <div style={{
        fontSize: 80,
        fontWeight: 700,
        lineHeight: 1.1,
        marginTop: 'auto',
      }}>
        {post.title}
      </div>
      <div style={{
        fontSize: 28,
        opacity: 0.7,
        marginTop: 24,
      }}>
        {post.excerpt}
      </div>
    </div>
  )
}

That’s the entire visual design. You’ll iterate on it for an afternoon, ship it, and never touch it again. The investment is one-time; the output ships forever.


Why Static Sites Win This Category

The runtime approach exists because it solves a problem dynamic sites have: the content might not exist when you build, the URLs might be open-ended, the request might carry data the server needs to incorporate. None of those constraints apply to a static site.

A static site has, by definition:

  • A bounded set of URLs known at build time.
  • All content available as files in the repository.
  • A deploy pipeline that can do arbitrary work before the site goes live.
  • A CDN-friendly output where every file is served as a static asset.

This is precisely the environment where build-time OG generation is strictly better than runtime. You’re not trading anything away. You’re not losing flexibility you actually use. You’re swapping a per-request compute cost for a one-time build cost, and the per-request cost was never paying for anything dynamic anyway.

It’s the same pattern that makes static sites win on hosting bills, on SEO consistency, on cache predictability, on uptime, and on edge performance. The dynamic version of the feature exists for a use case you don’t have. The static version exists for the use case you actually have. The only reason teams reach for the dynamic version is path-of-least-resistance — the framework’s tutorial showed them the runtime API first.


Cost Comparison, Concretely

Runtime, at scale, on a typical mid-traffic blog (10,000 OG fetches per month, average 250ms per render):

  • Function execution time: 2,500 seconds per month
  • Function invocations: 10,000 per month
  • On Vercel Pro: roughly $3–5/month if everything caches correctly, $20+ if it doesn’t
  • Cold start failures: 1–3% of crawler hits
  • Compute carbon: meaningful

Build-time, at any scale:

  • Function execution time: 0
  • Function invocations: 0
  • Hosting cost: included in static asset bandwidth (usually free or pennies)
  • Cold start failures: 0
  • Compute carbon: a one-time cost during build

Even at modest scale the build-time approach wins on every axis. The only thing it can’t do is generate cards for URLs that didn’t exist when you deployed, and a content site doesn’t have those.


A 30-Minute Migration

If you have a blog using runtime OG generation right now, here’s the path off it:

  1. Install satori and @resvg/resvg-js (or your preferred SVG-to-PNG library). Both are small, fast, and have no native dependencies that complicate deployment.
  2. Write a script that iterates your content directory and renders one PNG per post using your existing card design. Output to public/og/[slug].png.
  3. Add it to your build script so it runs before your static build.
  4. Update your post page’s metadata to point to the static path instead of the runtime endpoint.
  5. Delete the runtime endpoint and any associated middleware.
  6. Add a content-hash check so subsequent builds skip already-generated images.

That’s it. You’ll spend more time deciding which library to use than implementing it. The result is faster, cheaper, more reliable, and one fewer moving part in your stack.


The Pattern Behind the Pattern

This is a specific instance of a more general truth about static sites: any feature that can be precomputed should be. The web platform spent the last decade pushing more and more work to runtime — server components, edge functions, dynamic image optimization, on-demand revalidation — and all of it is genuinely useful when your content is dynamic. When your content is files in a repository, runtime computation is a tax you’re paying for flexibility you don’t have.

OG images are the most visible example, but the same logic applies to sitemaps, RSS feeds, search indices, related-post computations, and dozens of other features that frameworks default to runtime. Each one is cheaper, faster, and more reliable when generated at build time. None of them benefit from being dynamic for a content site.

The discipline isn’t to avoid frameworks. It’s to ask, for each piece of work the framework is doing at request time, whether the inputs ever actually change between requests. If they don’t, that work belongs in the build. The output belongs on a CDN. Your servers should do the smallest possible amount of work, and for content sites, that amount approaches zero.

OG image generation is a great place to start. The savings are immediate, the migration is short, and once you’ve done it, you’ll start noticing how many other things in your stack are paying runtime costs for outputs that never change.