Skip to content
intermediate Phase 9 · Metadata, SEO, and Social Sharing

Open Graph and Twitter Cards

Craft preview images and copy for shared links.

45m
0 problems
Topic Progress 0%

Anatomy of a Link Card: The Tags a Scraper Actually Reads

Paste a link into Slack, Discord, or iMessage and a card appears — title, site name, image. No JavaScript ran on your page for that; the platform's scraper fetched your URL as plain HTML and read the og: tags out of the server-rendered head. og:image and og:title are the two values that decide the card (with og:description and og:site-name filling the body text), and everything you learned about the Metadata API applies because those tags ride the same head-rendering path.

The exact tags and how the platforms treat them:

Tag Role Scraper behavior
og:title card headline falls back to <title>, then twitter:title on X
og:description one-to-two line body truncated aggressively; front-load the point
og:image the preview image fetched server-side; must be an absolute URL
og:image:width/height pre-resolve layout avoids surprises; some scrapers skip re-probing
og:image:alt accessibility + fallback text shown when images are blocked; increasingly required for compliance
og:site_name brand line usually inherited from the root layout's metadata
twitter:card summary vs summary_large_image the one control with no og: equivalent

Image dimensions are a per-platform contract, and getting them wrong is the most common visible bug. Facebook recommends 1200 × 630 (roughly 1.91:1) and will crop or letterbox anything else; X's large card wants a 2:1 image (1200 × 600 recommended, minimum 300 × 179); the small summary card wants a square thumbnail that survives being rendered at ~144 × 144. The safe universal answer for content sites: one 1200 × 630 image satisfies both platforms' large cards, which is why that resolution became the default everywhere. Two traps around sizing: declaring width/height that disagree with the actual file makes some scrapers mis-lay-out or reject the card, and file-size ceilings (Facebook caps at 8 MB, X at 5 MB) reject oversized exports silently — the card simply renders without the image.

The absolute-URL rule is worth repeating until it is reflex, because it is the bug that generates the most support tickets: relative paths break every scraper. og:image: /og/home.png in the raw HTML means "relative to whatever host fetched it," and platforms resolve it against your metadataBase only if you set it — otherwise Next emits a localhost fallback and the card ships an image URL that resolves for nobody outside your machine. Set metadataBase in the root layout from an env var, verify the emitted tag in the raw response, and consider the whole path tested.

Alt text deserves more than a checkbox answer. Platforms that suppress images (data-saver modes, corporate proxies blocking image hosts) render the alt as the card's fallback; screen readers on the sharing side announce it; and Google's image-search surfaces increasingly key on it. Write it as a caption a human would read — the post's headline plus context — not a keyword list. The remaining pieces of the pipeline, namely where the bytes come from, split into three strategies — static files, generated routes, and per-record generation — and they are the subject of the next two chapters.

Generating Cards on the Server: ImageResponse and Its Constraints

One shared /public/og-default.png covers a homepage. It cannot cover 800 blog posts, each of which should preview with its own headline — and hand-exporting images per publish is a content workflow nobody maintains. The App Router answer is a route that renders the card as JSX at request (or build) time, using ImageResponse from next/og:

// app/blog/[slug]/opengraph-image.tsx — file-convention OG image
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";

export const alt = "Blog post preview image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image(props: { params: Promise<{ slug: string }> }) {
  const { slug } = await props.params;
  const post = await getPost(slug);

  return new ImageResponse(
    (
      <div style={{
        width: "100%", height: "100%", display: "flex",
        flexDirection: "column", justifyContent: "space-between",
        padding: 80, background: "linear-gradient(135deg,#1e293b,#0f172a)", color: "white",
      }}>
        <span style={{ fontSize: 28, opacity: 0.7 }}>ACME BLOG</span>
        <div style={{ fontSize: 72, fontWeight: 700, lineHeight: 1.1 }}>{post.title}</div>
        <span style={{ fontSize: 32, opacity: 0.8 }}>{post.author}</span>
      </div>
    ),
    { ...size }
  );
}

The file convention is the elegant part: placing opengraph-image.tsx beside page.tsx makes Next generate the <meta property="og:image"> tag itself — dimensions, absolute URL, content type, and caching headers included — pointing at the route that renders it. No openGraph.images array to maintain; the image and the tag travel together. (A custom URL shape gets the same result by exporting an ImageResponse from any route file and referencing it explicitly in openGraph.images.)

The JSX here is not arbitrary — it runs through satori, which supports a subset of flexbox and CSS and enforces real constraints that produce the error messages people post about:

Constraint Consequence
Flexbox-first layout; most block layout unsupported design cards as flex columns/rows
No external images by default (no network in the renderer) inline data URLs or an images loader config for remote assets
Custom fonts must be registered load a font (Google fonts via next/font, or embed a TTF as a data URL) or get fallback-font warnings and metric shifts
Emoji needs an emoji font (Noto Color Emoji) otherwise missing glyphs
No CSS imports, no external stylesheets all styles inline or in the style prop

Runtime is the operational detail: ImageResponse was built for the Edge Runtime — short-lived, no heavyweight native deps — and current releases also support the Node Runtime, so check your version's docs rather than copying a config from an old tutorial. The route-level exports are how you declare intent (runtime, alt, size, and contentType default sensibly).

Caching decides whether the generation cost is paid once or constantly. A prerendered route generates its card at build — free at request time, frozen until the next deploy. A dynamic route re-renders per request unless you cache it; the sensible defaults are long-lived Cache-Control headers (public, max-age=31536000, immutable when the URL is content-addressed or the content never changes at that path) so platforms' CDN fetches it once per card, not once per share. The generateImageMetadata helper exists for pages that need several cards (one per product angle, say) — it returns an array of image descriptors with distinct ids and alt texts, each rendered by the same component with its own props.

The last design question: what text goes on the card. Front-load the headline — cards render the title twice (image and text), so a card that merely repeats the text line wastes its most valuable pixels. Brand mark, headline, and one distinguishing element (author, category, price) is the template that survives every platform's cropping.

Three Sources for Card Images, and How to Test What Platforms See

You have three ways to produce a card image, and production sites usually combine them. Choosing wrong shows up as either maintenance pain or broken previews, so hold them apart:

Source Best for Failure mode
Static file in /public the site-wide default card; rarely changing assets fine forever for a homepage image; wrong for per-record content
opengraph-image file convention one generated image per route, tag emitted automatically per-route generation cost; satori constraints on content
Explicit openGraph.images in metadata full control over tags (multiple images, per-locale variants, mixed static+generated) you own the URL and dimensions; drift between tag and file

The file convention and the explicit array can coexist deliberately: the convention covers routes with no override, and a page's generateMetadata returns openGraph.images where the card must be custom. Know the precedence — an explicit openGraph.images in metadata wins over the convention for that route — and use it as the escape hatch, not the default, because the convention is what keeps tags and bytes in sync.

Testing is a discipline, because "works in my Slack" is not a test. Every major platform exposes a debugger that refetches its own cache of your card — Facebook's Sharing Debugger, Slack's link unfurling preview, LinkedIn's Post Inspector — and each is the only truthful view of what that platform will render. Two behaviors to expect: scrapers cache aggressively (minutes to hours per platform, independent of your Cache-Control), so a fixed image may still preview wrong for a while, and the debuggers' "scrape again" button is how you bust it. Your own verification loop, before any platform:

curl -s https://acme.com/blog/why-postgres | grep -oE 'property="og:image"[^>]*'
# confirm: absolute https URL, correct dimensions declared, reachable:
curl -sI https://acme.com/blog/why-postgres/opengraph-image | head -3

The curl -I on the image URL is the step people skip: it is where you catch a localhost-fallback URL (missing metadataBase), a 404 from a convention file with a runtime error inside it, a text/html content type from a misconfigured route (some scrapers refuse non-image content types), and cache headers that will make your fix invisible for 24 hours. Combined with the head-level checks from Dynamic generateMetadata, that is the whole verification path — raw HTML tag, then reachable bytes, then platform debugger, in that order.

The accessibility and consistency close: one alt strategy (headline-derived, human-readable) across every card; dimensions declared to match the file; the brand line consistent so a timeline of shares reads as one voice. Two last field-level details show up in audits. og:image should be served over https — scrapers running behind https contexts reject or drop insecure image URLs, so a stray http link (common on migrated sites) silently removes the card image. And when the same image serves several routes, declare og:image:alt per route rather than reusing a generic string: platforms increasingly surface the alt where images are suppressed, and it is the only text a screen-reader user gets about the card's visual content. Neither detail touches ranking directly — social previews are not an SEO ranking factor — but they are the highest-leverage impression most pages get: the card is the click decision for a large share of referral traffic. Below the fold of the card, the crawler-visible work continues with the files that steer discovery itself: Sitemaps and robots.txt.

Quiz

1. A page declares og:image: "/og/post.png" with no metadataBase set. What does a scraper most likely receive?

Question 1 options

2. What does placing opengraph-image.tsx beside page.tsx accomplish?

Question 2 options

3. An ImageResponse card renders a custom font as a fallback and warns during render. What is the cause and fix?

Question 3 options

4. You fixed a card image and redeployed, but Facebook still previews the old one after an hour. What is the correct explanation?

Question 4 options

Flashcards

Question

Which tags build a link card, and which single field has no og: equivalent?

Answer

og:title, og:description, og:image (absolute URL!), og:site_name build the card cross-platform; twitter:card is the override layer, with 'summary' vs 'summary_large_image' having no og: equivalent — it chooses small-thumbnail vs wide-hero rendering on X.

Question

The universal card image dimensions, and the per-platform specifics.

Answer

1200 × 630 (~1.91:1) satisfies Facebook's recommendation and X's large card; X's summary card wants a square that survives ~144 px rendering; file-size ceilings (FB 8 MB, X 5 MB) silently drop oversized images. Declare width/height to match the real file.

Question

What is ImageResponse and what does it run through?

Answer

A next/og API that renders a card as JSX to PNG at request/build time — typically in an opengraph-image.tsx file convention, which makes Next emit the og:image tag automatically. It renders via satori: flexbox-subset CSS, no external images or stylesheets by default, fonts must be registered, emoji needs a color-emoji font.

Question

Three card-image sources and when each is right.

Answer

Static /public file: the site-wide default. opengraph-image convention: per-route generated image with the tag kept in sync. Explicit openGraph.images in generateMetadata: overrides — multiple images, per-locale variants, mixed sources (explicit wins over the convention for that route).

Question

The card verification loop, in order.

Answer

1) grep the raw HTML for the og:image tag: absolute URL, correct declared dimensions. 2) curl -I the image URL: reachable, image/png content type, sane cache headers (catches localhost fallbacks and convention-file runtime errors). 3) Platform debugger refetch (FB Sharing Debugger, Slack preview, LinkedIn Post Inspector) — each caches scraped cards for hours independently of your Cache-Control.

Revision Notes

Key Takeaways

  • 1. Cards are built from server-rendered og: tags by JS-less scrapers; twitter:card is the platform-specific override layer
  • 2. One 1200 × 630 image satisfies the major platforms; mismatched dimensions or over-size files fail silently by dropping the image
  • 3. Every image URL must be absolute — metadataBase is the guard against the localhost-fallback bug
  • 4. ImageResponse + the opengraph-image file convention generate per-record cards as JSX under satori's constraints (flexbox, registered fonts, no external assets)
  • 5. Verify in three layers — raw tag, image headers, platform debugger — because each platform caches scraped cards for hours regardless of your headers

Interview Tips

  • • "How do social previews work?" — the scraper fetches raw HTML, reads og: tags from the server-rendered head, downloads og:image by URL; no JS runs, which is why the whole pipeline is a server concern
  • • "One image or per-page images?" — a static default for the site, the opengraph-image convention for per-route cards (tags auto-emitted), explicit openGraph.images for overrides; cite the precedence rule
  • • "Why is our preview image broken?" — walk the checkable chain: metadataBase (localhost fallback), reachability and content type of the image URL, dimension/size limits, then platform cache and its debugger
  • • "What is ImageResponse's runtime story?" — designed for the Edge Runtime, Node support in current releases; satori's subset (flex, registered fonts, embedded images) is the practical design constraint on card layouts

Cheat Sheet

Open Graph and Twitter Cards

The card stack: scrapers fetch raw HTML, read og:* from the server-rendered head, fetch og:image as a plain URL — no JS involved. twitter:card overrides on X (summary_large_image for the wide card).

Image contract: 1200 × 630 universal (FB recommended, X large-card compatible) · square for summary · match declared width/height to the file · size ceilings (FB 8 MB, X 5 MB) fail silently · absolute URLs only — set metadataBase or Next falls back to localhost.

Three sources:

Source Use
/public static file site-wide default
opengraph-image.tsx convention per-route generated card; Next emits the tag (URL, dims, type, cache headers)
openGraph.images in generateMetadata explicit override — multiple images, locales; wins over the convention

ImageResponse (next/og):

export const alt = "Blog post preview";
export const size = { width: 1200, height: 630 };
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return new ImageResponse((<div style={{ display: "flex", /* satori subset */ }}>{title}</div>), { ...size });
}

Satori limits: flexbox subset · no external images/styles by default · fonts registered (next/font or data-URL TTF) · emoji needs Noto Color Emoji. Cache generated cards (max-age=31536000, immutable when safe); prerendered routes generate at build.

Verify: raw tag (absolute URL + dims) → curl -I the image URL (200, image/png) → platform debugger refetch (each caches for hours, ignores your headers).

Continue Learning

Before You Start

Related Reading