---
title: Generate social card images
description: "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."
canonical_url: https://usepennington.net/how-to/feeds/social-cards/
sidecar_url: https://usepennington.net/how-to/feeds/social-cards.md
content_hash: sha256:45f78847cf05a34e1e3a0656987a6288b0b918a669aa464056171eef047a684c
tokens: 2487
uid: how-to.feeds.social-cards
reading_time_minutes: 5
---

Guides
# 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](https://usepennington.net/tutorials/getting-started/first-site.md) if not)
 - A production origin for `CanonicalBaseUrl` — OpenGraph crawlers require an absolute `og:image` URL, 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:

 
```csharp:symbol
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:

 
```csharp:symbol
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:

 
```yaml
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](https://usepennington.net/explanation/core/head-subsystem.md)). 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.

 
```csharp
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.

 
```csharp
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](https://usepennington.net/how-to/response-pipeline/head-contributor.md) for the full contributor surface):

 
```csharp
builder.Services.AddHeadContributor<CardOverrideHeadContributor>();
```

 
## Result

 
Every recorded page carries meta tags pointing at its card, absolute when `CanonicalBaseUrl` is set:

 
```html
<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 run` and open `/social-cards/<page-path>.png` — expect your rendered card; a 404 means the page projects no record (or `Render` returned `null`)
 - View-source any post and confirm the three meta tags above point at the page's own card
 - Run `dotnet run -- build output` and confirm `output/social-cards/` contains one `.png` per page, including `index.png` on BlogSite
 
 
## Related

 
 - Explanation: [The head subsystem](https://usepennington.net/explanation/core/head-subsystem.md) — how card meta tags compose with page-authored tags
 - How-to: [Add tags to the document head](https://usepennington.net/how-to/response-pipeline/head-contributor.md) — write your own head contributor
 - Reference: [Pennington.BlogSite.BlogSiteOptions](https://usepennington.net/reference/api/blog-site-options.md)
 - How-to: [Publish an RSS feed](https://usepennington.net/how-to/feeds/rss.md)
 
 
[Previous
                
                Publish a sitemap](https://usepennington.net/how-to/feeds/sitemap.md)[Next
                    
                Source content from outside the markdown pipeline](https://usepennington.net/how-to/content-services/custom-content-service.md)