C# Guide
Published March 29, 2026 · 14 min read

Generate Dynamic OG Images in C# with OGPeek API

C# developers building web apps with ASP.NET Core, interactive UIs with Blazor, or cross-platform apps with .NET MAUI all face the same tedious problem: generating social preview images requires either a headless browser burning 200MB of RAM per instance, or verbose System.Drawing code that feels like a relic from 2005. OGPeek eliminates all of that. Construct a URL with your title and branding, call it with HttpClient, and get back a 1200×630 PNG. This guide covers every C# integration pattern—from basic HttpClient calls to ASP.NET Core middleware, Blazor components, background services for batch generation, and .NET MAUI live previews.

OG image preview for this article generated by OGPeek

Why .NET Apps Need Dynamic OG Images

Open Graph images are the thumbnails that appear when someone shares a link on Twitter, LinkedIn, Slack, or Discord. The platform fetches your page’s HTML, reads the <meta property="og:image"> tag, and renders the image in a rich preview card. Without one, your link is a plain text URL that vanishes into the feed.

The impact is measurable: links with image previews receive 2–3x higher click-through rates than links without them. If you are building an ASP.NET Core web app, a Blazor dashboard, or a .NET MAUI app with sharing features, every link your users share without a proper OG image is leaving engagement on the table.

The traditional approaches in the .NET ecosystem all have significant downsides:

A URL-based API eliminates all of this. You build a URL string with your title and branding parameters, the API returns a production-ready PNG, and the image is cached at the CDN edge. Your C# code does zero image processing.

How OGPeek Works

OGPeek is a URL-based API that generates Open Graph images from query parameters. No API key is required for the free tier (50 images per day, watermarked). A complete request URL looks like this:

https://todd-agent-prod.web.app/api/v1/og
  ?title=My+ASP.NET+App
  &subtitle=Built+with+.NET+8
  &template=gradient
  &theme=dark
  &brandColor=%23F59E0B

The parameters control the generated image:

Because it is a standard GET request that returns a PNG, any social media crawler can fetch it directly. No JavaScript rendering, no authentication, no cookies. The URL is the image.

OGPeek Service Class with HttpClient

Start by defining a clean service class that encapsulates the OGPeek API. This follows .NET best practices: a single HttpClient instance managed through dependency injection, and a strongly-typed parameter record:

using System.Net.Http;
using System.Web;

public record OGPeekParams(
    string Title,
    string? Subtitle = null,
    string Template = "gradient",
    string Theme = "dark",
    string BrandColor = "#F59E0B"
);

public class OGPeekService
{
    private const string BaseUrl =
        "https://todd-agent-prod.web.app/api/v1/og";

    private readonly HttpClient _httpClient;

    public OGPeekService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    /// <summary>Build the full OGPeek URL with query parameters.</summary>
    public static string BuildUrl(OGPeekParams p)
    {
        var query = HttpUtility.ParseQueryString(string.Empty);
        query["title"] = p.Title.Length > 60
            ? p.Title[..60]
            : p.Title;
        query["template"] = p.Template;
        query["theme"] = p.Theme;
        query["brandColor"] = p.BrandColor;

        if (!string.IsNullOrEmpty(p.Subtitle))
        {
            query["subtitle"] = p.Subtitle.Length > 80
                ? p.Subtitle[..80]
                : p.Subtitle;
        }

        return $"{BaseUrl}?{query}";
    }

    /// <summary>Fetch the OG image as a byte array.</summary>
    public async Task<byte[]> FetchImageAsync(
        OGPeekParams parameters,
        CancellationToken ct = default)
    {
        var url = BuildUrl(parameters);
        var response = await _httpClient.GetAsync(url, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsByteArrayAsync(ct);
    }
}

The HttpUtility.ParseQueryString method handles all percent-encoding automatically. The # in BrandColor becomes %23 in the final URL without any manual escaping. Using a C# record for OGPeekParams gives you immutability, value equality, and with expressions for free: p with { Theme = "midnight" }.

Important: Never create a new HttpClient per request. In .NET, HttpClient is designed to be long-lived and reused. Creating and disposing instances causes socket exhaustion. Register OGPeekService with IHttpClientFactory in your DI container for production use.

Register with Dependency Injection

In your Program.cs, register the OGPeekService using the typed HttpClient pattern. This is the recommended approach in ASP.NET Core for managing HTTP clients:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<OGPeekService>(client =>
{
    client.DefaultRequestHeaders.Add("Accept", "image/png");
    client.Timeout = TimeSpan.FromSeconds(10);
});

var app = builder.Build();
// ... configure middleware and endpoints

The AddHttpClient<T> method registers the service and configures a dedicated HttpClient instance with proper lifetime management. The underlying HttpMessageHandler is pooled and recycled automatically, preventing socket exhaustion without any manual using blocks or disposal logic.

ASP.NET Core Controller

For ASP.NET Core MVC or API projects, create a controller that generates OG image URLs from your model data and optionally proxies the image through your own domain:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class OGImageController : ControllerBase
{
    private readonly OGPeekService _ogPeek;

    public OGImageController(OGPeekService ogPeek)
    {
        _ogPeek = ogPeek;
    }

    /// <summary>Proxy OG image through your domain.</summary>
    [HttpGet]
    [ResponseCache(Duration = 86400, Location = ResponseCacheLocation.Any)]
    public async Task<IActionResult> Get(
        [FromQuery] string title,
        [FromQuery] string? subtitle = null)
    {
        var parameters = new OGPeekParams(
            Title: title,
            Subtitle: subtitle
        );

        try
        {
            var imageBytes = await _ogPeek.FetchImageAsync(parameters);
            return File(imageBytes, "image/png");
        }
        catch (HttpRequestException)
        {
            return StatusCode(502, "Failed to generate OG image");
        }
    }
}

// MVC controller for blog pages with dynamic og:image
[Route("blog")]
public class BlogController : Controller
{
    private readonly PostRepository _posts;

    public BlogController(PostRepository posts)
    {
        _posts = posts;
    }

    [HttpGet("{slug}")]
    public async Task<IActionResult> Detail(string slug)
    {
        var post = await _posts.FindBySlugAsync(slug);
        if (post is null) return NotFound();

        var ogImageUrl = OGPeekService.BuildUrl(new OGPeekParams(
            Title: post.Title,
            Subtitle: $"{post.Author} · {post.ReadTimeMinutes} min read"
        ));

        ViewData["OgImageUrl"] = ogImageUrl;
        return View(post);
    }
}

In your Razor view, use the ViewData value to set the meta tag:

<!-- Views/Blog/Detail.cshtml -->
<head>
  <title>@Model.Title — My Blog</title>

  <!-- Open Graph -->
  <meta property="og:title" content="@Model.Title">
  <meta property="og:description" content="@Model.Excerpt">
  <meta property="og:type" content="article">
  <meta property="og:image" content="@ViewData["OgImageUrl"]">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">

  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="@Model.Title">
  <meta name="twitter:image" content="@ViewData["OgImageUrl"]">
</head>

The controller builds the URL, the view renders it. No image processing in the view, no URL construction in HTML. The ResponseCache attribute on the proxy endpoint adds Cache-Control headers so CDNs and browsers cache the image for 24 hours.

ASP.NET Core Middleware Approach

If you prefer a middleware-based approach that automatically injects OG image URLs into every page response, you can write a custom middleware that intercepts HTML responses and adds the meta tag. This is useful for content-heavy sites where every page needs a unique OG image:

public class OGPeekMiddleware
{
    private readonly RequestDelegate _next;

    public OGPeekMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Only apply to HTML page requests (not API calls or static files)
        if (!context.Request.Path.StartsWithSegments("/blog"))
        {
            await _next(context);
            return;
        }

        // Extract page title from route or query
        var slug = context.Request.RouteValues["slug"]?.ToString();
        if (string.IsNullOrEmpty(slug))
        {
            await _next(context);
            return;
        }

        // Build OGPeek URL and store in HttpContext.Items
        // so downstream Razor pages can access it
        var ogUrl = OGPeekService.BuildUrl(new OGPeekParams(
            Title: slug.Replace("-", " "),
            Subtitle: "My Blog"
        ));

        context.Items["OgImageUrl"] = ogUrl;
        await _next(context);
    }
}

// Register in Program.cs
// app.UseMiddleware<OGPeekMiddleware>();

The middleware pattern is best for applications where OG images follow a consistent formula based on the URL or route data. For more complex scenarios where the OG image depends on database content, the controller approach gives you more control.

Direct URL vs. proxy: You do not have to proxy through ASP.NET Core. You can embed the OGPeek URL directly in your Razor view’s og:image meta tag. Proxying adds latency on the first request but gives you control over caching headers and keeps all URLs on your domain. Choose based on whether domain consistency matters for your use case.

Blazor Component for Live Preview

When building a Blazor application—whether Server or WebAssembly—you can create a reusable component that shows a live preview of the OG image as users edit content. This is especially valuable for CMS dashboards and blog editors:

@* Components/OGImagePreview.razor *@

@inject HttpClient Http

<div class="og-preview-container">
    <h4>Social Preview</h4>

    @if (_isLoading)
    {
        <div class="og-loading">Generating preview...</div>
    }
    else if (_errorMessage is not null)
    {
        <div class="og-error">@_errorMessage</div>
    }
    else
    {
        <img src="@_imageUrl"
             alt="OG image preview"
             style="width:100%; border-radius:8px;" />
    }
</div>

@code {
    [Parameter] public string Title { get; set; } = "Untitled";
    [Parameter] public string? Subtitle { get; set; }
    [Parameter] public string Template { get; set; } = "gradient";
    [Parameter] public string Theme { get; set; } = "dark";

    private string? _imageUrl;
    private string? _errorMessage;
    private bool _isLoading;
    private CancellationTokenSource? _debounceTokenSource;

    protected override async Task OnParametersSetAsync()
    {
        // Debounce: wait 500ms after last parameter change
        _debounceTokenSource?.Cancel();
        _debounceTokenSource = new CancellationTokenSource();
        var token = _debounceTokenSource.Token;

        try
        {
            await Task.Delay(500, token);

            _isLoading = true;
            _errorMessage = null;
            StateHasChanged();

            _imageUrl = OGPeekService.BuildUrl(new OGPeekParams(
                Title: Title,
                Subtitle: Subtitle,
                Template: Template,
                Theme: Theme
            ));

            _isLoading = false;
        }
        catch (TaskCanceledException)
        {
            // Debounce cancelled — a newer parameter change is pending
        }
    }

    public void Dispose()
    {
        _debounceTokenSource?.Cancel();
        _debounceTokenSource?.Dispose();
    }
}

Use the component in any Blazor page:

@* Pages/PostEditor.razor *@
@page "/admin/posts/edit/{Id:int}"

<h2>Edit Post</h2>

<input @bind="postTitle" @bind:event="oninput"
       placeholder="Post title..." />
<input @bind="postSubtitle" @bind:event="oninput"
       placeholder="Subtitle..." />

<OGImagePreview Title="@postTitle"
                Subtitle="@postSubtitle" />

@code {
    [Parameter] public int Id { get; set; }
    private string postTitle = "";
    private string postSubtitle = "";
}

The component debounces parameter changes with a 500ms delay, preventing API calls on every keystroke. Because Blazor re-renders when parameters change, the OnParametersSetAsync lifecycle method is the natural place to react. The CancellationTokenSource ensures that rapid changes cancel pending requests, so only the final state triggers an image load.

Blazor SEO tip: In .NET 8+ Blazor, use HeadContent to dynamically set the og:image meta tag from server-rendered components. For earlier versions, set meta tags in _Host.cshtml or _Layout.cshtml using Razor expressions. Social media crawlers do not execute JavaScript, so the meta tag must be present in the initial HTML response.

Background Service for Batch Generation

When you need to generate OG images for many items at once—migrating a blog to new preview images, pre-warming the CDN cache after a template change, or generating images for an entire product catalog—use a BackgroundService with SemaphoreSlim for controlled concurrency:

using System.Collections.Concurrent;

public class OGImageBatchService : BackgroundService
{
    private readonly OGPeekService _ogPeek;
    private readonly ILogger<OGImageBatchService> _logger;

    public OGImageBatchService(
        OGPeekService ogPeek,
        ILogger<OGImageBatchService> logger)
    {
        _ogPeek = ogPeek;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        var items = new List<OGPeekParams>
        {
            new("Understanding Blazor SSR", "ASP.NET Core"),
            new("Minimal APIs Deep Dive", ".NET 8"),
            new("EF Core Performance Tips", "Data Access"),
            new("SignalR Real-Time Apps", "WebSockets"),
            new("gRPC in .NET", "Microservices"),
        };

        var results = await GenerateBatchAsync(
            items,
            maxConcurrency: 3,
            stoppingToken);

        _logger.LogInformation(
            "Generated {Count} OG images", results.Count);
    }

    private async Task<ConcurrentBag<(string Title, byte[] Data)>>
        GenerateBatchAsync(
            List<OGPeekParams> items,
            int maxConcurrency,
            CancellationToken ct)
    {
        var semaphore = new SemaphoreSlim(maxConcurrency);
        var results = new ConcurrentBag<(string, byte[])>();

        var tasks = items.Select(async parameters =>
        {
            await semaphore.WaitAsync(ct);
            try
            {
                var bytes = await _ogPeek.FetchImageAsync(
                    parameters, ct);
                results.Add((parameters.Title, bytes));

                _logger.LogInformation(
                    "Generated OG image for '{Title}' ({Size} bytes)",
                    parameters.Title, bytes.Length);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex,
                    "Failed to generate OG image for '{Title}'",
                    parameters.Title);
            }
            finally
            {
                semaphore.Release();
            }
        });

        await Task.WhenAll(tasks);
        return results;
    }
}

Register the service in Program.cs:

builder.Services.AddHostedService<OGImageBatchService>();

The SemaphoreSlim limits how many requests run in parallel. This is important for two reasons: the free tier has a daily limit of 50 images, and even on paid plans, you should be a good API citizen and avoid sending hundreds of simultaneous requests. A concurrency of 3–5 keeps throughput high without overwhelming the server. Each failed request is caught individually, so one bad title does not abort the entire batch. The ConcurrentBag is thread-safe for collecting results from parallel tasks.

.NET MAUI Live Preview

If you are building a cross-platform app with .NET MAUI that includes content sharing, you can show users a live preview of their OG image before they publish. The approach is similar to the Blazor component but uses MAUI’s Image control:

<!-- Views/PostEditorPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyApp.Views.PostEditorPage">
    <VerticalStackLayout Padding="16" Spacing="12">
        <Entry x:Name="TitleEntry"
               Placeholder="Post title..."
               TextChanged="OnTitleChanged" />

        <Label Text="Social Preview"
               FontAttributes="Bold"
               FontSize="16" />

        <Border StrokeShape="RoundRectangle 12"
                Stroke="#333">
            <Image x:Name="OGPreviewImage"
                   Aspect="AspectFill"
                   HeightRequest="180" />
        </Border>

        <ActivityIndicator x:Name="LoadingIndicator"
                           IsVisible="False"
                           IsRunning="False" />
    </VerticalStackLayout>
</ContentPage>
// Views/PostEditorPage.xaml.cs
public partial class PostEditorPage : ContentPage
{
    private CancellationTokenSource? _debounceTokenSource;

    public PostEditorPage()
    {
        InitializeComponent();
    }

    private async void OnTitleChanged(
        object sender, TextChangedEventArgs e)
    {
        _debounceTokenSource?.Cancel();
        _debounceTokenSource = new CancellationTokenSource();
        var token = _debounceTokenSource.Token;

        try
        {
            await Task.Delay(600, token);

            LoadingIndicator.IsVisible = true;
            LoadingIndicator.IsRunning = true;

            var title = string.IsNullOrWhiteSpace(e.NewTextValue)
                ? "Untitled Post"
                : e.NewTextValue;

            var url = OGPeekService.BuildUrl(
                new OGPeekParams(Title: title));

            OGPreviewImage.Source = ImageSource.FromUri(
                new Uri(url));

            LoadingIndicator.IsVisible = false;
            LoadingIndicator.IsRunning = false;
        }
        catch (TaskCanceledException)
        {
            // Debounce cancelled
        }
    }
}

The MAUI Image control handles the HTTP request and caching internally when given a URI source. The debounce pattern with CancellationTokenSource prevents excessive network requests as the user types. This works on iOS, Android, macOS, and Windows from a single codebase.

Pricing and Rate Limits

OGPeek offers three tiers to match your usage:

All tiers use the same API endpoint and the same parameters. Upgrading is a matter of adding your API key to the request—no code changes beyond that.

Try OGPeek free — 50 images/day, no API key

Generate dynamic Open Graph images for your C# app in minutes. No signup required.

View pricing →

Wrapping Up

C# and .NET give you multiple clean integration points for OGPeek depending on where you are in the stack:

The underlying principle is the same regardless of which pattern you choose: build a URL with your parameters, point it at the OGPeek API, and let the CDN handle image generation and caching. Your C# code does zero image processing, references zero native image libraries, and manages zero image files on disk.

For more on the OGPeek API, see the complete API reference. For framework-specific guides, check out Kotlin + OGPeek, Django + OGPeek, or Next.js + OGPeek.

The Peek Suite