Generate social card images
Configure SocialCards so every page ships a generated OpenGraph/Twitter image: Pennington discovers, serves, bakes, and meta-tags one card per page; you supply the drawing code.
When a page is shared on a social network or chat app, the preview image comes from its og:image / twitter:image meta tags. Setting SocialCards turns on generated cards: Pennington discovers one card route per page (/social-cards/{page-path}.png), renders each on demand during development, bakes them all in the static build, and points every page's meta tags at its own card. You own only the drawing, through a single Render hook — bring whatever image library fits (ImageSharp, SkiaSharp, or a headless browser screenshotting an HTML template).
The same option works on every host shape: DocSiteOptions and BlogSiteOptions forward a SocialCards property; a bare host sets it on PenningtonOptions directly.
Before you begin
- A working Pennington site (see Create your first Pennington site if not)
- A production origin for
CanonicalBaseUrl— OpenGraph crawlers require an absoluteog:imageURL, so without it the tags emit root-relative paths that work in dev but do not unfurl when shared
Turn on generated cards
Set SocialCards with a Render hook. This is the complete BlogSiteSocialCardsExample host:
using Pennington.BlogSite;
using Pennington.SocialCards;
var builder = WebApplication.CreateBuilder(args);
// Generated social cards. Pennington owns the integration — it discovers one card
// route per content page (so the static build bakes them), serves each on demand at
// `/social-cards/<page>.png`, and points every post's `og:image`/`twitter:image` at it.
// The host owns only the drawing, via `SocialCardOptions.Render`: it receives the page's
// resolved metadata (title, description, date, tags, the card's own absolute URL) and
// returns PNG bytes — or null to skip a page.
builder.Services.AddBlogSite(() => new BlogSiteOptions
{
SiteTitle = "Social Cards Blog",
SiteDescription = "A BlogSite host demonstrating generated OpenGraph social cards.",
CanonicalBaseUrl = "https://example.com",
AuthorName = "Author Name",
SocialCards = new SocialCardOptions
{
// This sample paints a solid placeholder card with a dependency-free PNG encoder so the
// example needs no image library. In a real app, draw `request.Title` /
// `request.Description` onto the canvas with an image library (ImageSharp, SkiaSharp) or
// screenshot an HTML template with Playwright — `request` carries everything you need,
// and the IServiceProvider lets the renderer resolve registered services (font caches,
// theme options, ...).
Render = (request, services, _) =>
{
// The home page gets its own card too — BlogSite projects a site-identity record
// for `/` (title/description from the options), rendered at /social-cards/index.png.
// CanonicalPath is how a renderer varies the design per page: purple for the home
// card here, slate blue for posts.
var png = request.CanonicalPath.Value == "/"
? SocialCardPainter.SolidCard(request.Width, request.Height, 0x6D, 0x28, 0xD9)
: SocialCardPainter.SolidCard(request.Width, request.Height);
return Task.FromResult<byte[]?>(png);
},
},
});
var app = builder.Build();
app.UseBlogSite();
await app.RunBlogSiteAsync(args);
Render runs whenever a card route is requested: once per page during a static build, and on demand in development each time the route is hit. It receives the page's resolved metadata, the request's IServiceProvider (resolve anything registered — a font cache, theme options), and a cancellation token. Return the image bytes, or null to skip the page: its card route serves 404 and is omitted from the build.
Everything the hook receives arrives on the request:
public sealed record SocialCardRequest(
string Title,
string? Description,
DateTime? Date,
UrlPath CanonicalPath,
string CardUrl,
string? Locale,
string SiteTitle,
string? SiteDescription,
IFrontMatter Metadata,
int Width,
int Height);
Metadata is the page's full typed front matter, so a renderer can pattern-match capability interfaces — tags, author, series — beyond the common fields. The remaining SocialCardOptions members have working defaults: cards publish under /social-cards/, at the 1200x630 OpenGraph standard, as image/png.
Which pages get cards
A card exists for every page that projects a content record — the discovery seam that carries a page's typed front matter.
Markdown pages
Automatic on every host shape: DocSite pages, DocSite and BlogSite blog posts, and markdown registered with AddMarkdownContent<T> on a bare host all project records, so each gets a card and the matching meta tags.
The home page
BlogSite projects a site-identity record for / — title and description from BlogSiteOptions — so the root URL gets a card at /social-cards/index.png with no extra configuration. To give it a distinct design, branch on request.CanonicalPath, as the example above does.
Razor pages
A routed @page component gets a card when it has sidecar metadata — a {Component}.razor.metadata.yml file next to the component:
title: About us
description: Who we are and why we build this.
A Razor page without a sidecar projects no record, so it gets no card and no card meta tags.
Pages to skip
Return null from Render. The card route 404s, the build omits the file, and the page keeps any site-wide default image instead.
Use your own image for some pages
A page that authors its own og:image wins — the generated card's tags only fill gaps, through the same head reconciliation every contributor goes through (see the head subsystem). How a page declares that image depends on the host shape.
BlogSite
SocialMediaImageUrlFactory is the per-post hook: return an image URL to use it for that post, or null to fall back to the generated card.
new BlogSiteOptions
{
SocialMediaImageUrlFactory = post =>
post.FrontMatter.Tags.Contains("announcement") ? "/img/announcement-card.png" : null,
SocialCards = new SocialCardOptions { Render = ... },
}
DocSite or a bare host
There is no per-page factory option here, so override the card the way any tag overrides a built-in one: a head contributor that emits og:image from a band below the card's. The card contributor sits at HeadOrder.Page, so a lower Order wins the slot through the lowest-order-wins rule, and the card's tag steps aside for those pages. Read the per-page image off the resolved record's front matter (give your front-matter type an image field, or branch on tags as below) and skip pages that should keep the generated card.
internal sealed class CardOverrideHeadContributor : IHeadContributor
{
// Below HeadOrder.Page so this wins the og:image slot against the generated card.
public int Order => HeadOrder.Page - 1;
public bool ShouldContribute(HeadContext context) =>
context.Record?.Metadata is ITaggable { Tags: var tags } && tags.Contains("announcement");
public Task ContributeAsync(HeadContext context, HeadBuilder head)
{
head.Property("og:image", "/img/announcement-card.png");
head.Meta("twitter:image", "/img/announcement-card.png");
return Task.CompletedTask;
}
}
Register it after the host wiring (see Add tags to the document head for the full contributor surface):
builder.Services.AddHeadContributor<CardOverrideHeadContributor>();
Result
Every recorded page carries meta tags pointing at its card, absolute when CanonicalBaseUrl is set:
<meta property="og:image" content="https://example.com/social-cards/blog/hello-card.png" data-head="meta:prop:og:image">
<meta name="twitter:image" content="https://example.com/social-cards/blog/hello-card.png" data-head="meta:name:twitter:image">
<meta name="twitter:card" content="summary_large_image" data-head="meta:name:twitter:card">
The static build bakes one PNG per page under output/social-cards/, mirroring the page tree, with the home page reserved as index.png.
Verify
- Run
dotnet runand open/social-cards/<page-path>.png— expect your rendered card; a 404 means the page projects no record (orRenderreturnednull) - View-source any post and confirm the three meta tags above point at the page's own card
- Run
dotnet run -- build outputand confirmoutput/social-cards/contains one.pngper page, includingindex.pngon BlogSite
Related
- Explanation: The head subsystem — how card meta tags compose with page-authored tags
- How-to: Add tags to the document head — write your own head contributor
- Reference: Pennington.BlogSite.BlogSiteOptions
- How-to: Publish an RSS feed