Generate Dynamic OG Images in Swift with OGPeek API
Swift developers building web backends with Vapor, iOS apps with share extensions, or macOS tools that publish content all face the same problem: generating social preview images is painful. CoreGraphics requires dozens of lines of drawing code. Server-side rendering with headless browsers is heavy and slow. OGPeek eliminates all of that. Construct a URL with your title and branding, call it with URLSession, and get back a 1200×630 PNG. This guide covers every integration pattern—from basic URLSession calls to Vapor route handlers to SwiftUI preview components and concurrent batch generation with TaskGroup.
Why Swift Apps Need Dynamic OG Images
Open Graph images are the thumbnails that appear when someone shares a link on Twitter, LinkedIn, Slack, iMessage, 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 blends into the feed and gets ignored.
The impact is measurable: links with image previews receive 2–3x higher click-through rates than links without them. If you are building a Vapor web app, a content platform, or an iOS app with sharing features, every link your users share without a proper OG image is leaving engagement on the table.
The traditional approach in Swift is to draw images with CoreGraphics or use a WebKit snapshot. Both work, but both come with significant downsides:
- CoreGraphics requires manual text layout with
CTFramesetter, careful coordinate math, font loading, and 80–150 lines of drawing code per layout. It produces great results but the development time is high and the code is hard to maintain. - WebKit snapshots require spinning up a
WKWebView, loading HTML, waiting for rendering, and capturing the viewport. This works on macOS and iOS but is unavailable in server-side Swift (Vapor, Hummingbird) and adds 500ms+ per image. - Static images mean every shared link gets the same generic preview. A blog post about SwiftUI animations and a blog post about Combine publishers look identical when shared.
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 Swift 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+Swift+App
&subtitle=Built+with+Vapor
&template=gradient
&theme=dark
&brandColor=%23F59E0B
The parameters control the generated image:
title— Primary headline text. Keep it under 60 characters for best results.subtitle— Secondary text below the title. Author name, category, read time, or a tagline.template— Visual layout:gradient,minimal,split, and more.theme— Color scheme:dark,midnight,dawn,slate.brandColor— Your accent color as a URL-encoded hex value.%23F59E0Bdecodes to#F59E0B.
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.
Swift Struct for API Parameters
Start by defining a clean Swift struct that encapsulates the OGPeek API parameters. This gives you type safety and a single place to manage defaults:
import Foundation
struct OGPeekParams {
let title: String
var subtitle: String? = nil
var template: String = "gradient"
var theme: String = "dark"
var brandColor: String = "#F59E0B"
static let baseURL = "https://todd-agent-prod.web.app/api/v1/og"
/// Build the full OGPeek URL with query parameters.
func buildURL() -> URL? {
var components = URLComponents(string: Self.baseURL)
var queryItems = [
URLQueryItem(name: "title", value: String(title.prefix(60))),
URLQueryItem(name: "template", value: template),
URLQueryItem(name: "theme", value: theme),
URLQueryItem(name: "brandColor", value: brandColor),
]
if let subtitle = subtitle {
queryItems.append(
URLQueryItem(name: "subtitle", value: String(subtitle.prefix(80)))
)
}
components?.queryItems = queryItems
return components?.url
}
}
Using URLComponents and URLQueryItem handles all percent-encoding automatically. You never need to manually encode ampersands, spaces, or hash symbols. The # in brandColor becomes %23 in the final URL without any extra work.
Basic URLSession Call (Completion Handler)
If you need to support older Swift versions or prefer the callback pattern, here is a straightforward URLSession call that fetches the generated OG image as PNG data:
import Foundation
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endif
func fetchOGImage(
params: OGPeekParams,
completion: @escaping (Result<PlatformImage, Error>) -> Void
) {
guard let url = params.buildURL() else {
completion(.failure(URLError(.badURL)))
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let data = data,
let image = PlatformImage(data: data) else {
completion(.failure(URLError(.badServerResponse)))
return
}
completion(.success(image))
}
task.resume()
}
// Usage
let params = OGPeekParams(
title: "Building a REST API with Vapor",
subtitle: "Swift Server-Side",
template: "gradient",
theme: "dark"
)
fetchOGImage(params: params) { result in
switch result {
case .success(let image):
print("Image size: \(image.size)")
case .failure(let error):
print("Failed: \(error.localizedDescription)")
}
}
The function uses conditional compilation (#if canImport) to work on both iOS (UIImage) and macOS (NSImage). The API returns a standard PNG, so both image types initialize from the raw data without any conversion.
Async/Await Version (Swift 5.5+)
Modern Swift code should use async/await for cleaner control flow and better error handling. Here is the same functionality using structured concurrency:
import Foundation
enum OGPeekError: Error, LocalizedError {
case invalidURL
case badResponse(statusCode: Int)
case invalidImageData
var errorDescription: String? {
switch self {
case .invalidURL:
return "Failed to construct OGPeek URL"
case .badResponse(let code):
return "OGPeek API returned status \(code)"
case .invalidImageData:
return "Response data is not a valid image"
}
}
}
func fetchOGImage(params: OGPeekParams) async throws -> PlatformImage {
guard let url = params.buildURL() else {
throw OGPeekError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw OGPeekError.badResponse(statusCode: 0)
}
guard httpResponse.statusCode == 200 else {
throw OGPeekError.badResponse(statusCode: httpResponse.statusCode)
}
guard let image = PlatformImage(data: data) else {
throw OGPeekError.invalidImageData
}
return image
}
// Usage
Task {
let params = OGPeekParams(
title: "Understanding Swift Concurrency",
subtitle: "async/await deep dive"
)
do {
let image = try await fetchOGImage(params: params)
print("Generated OG image: \(image.size)")
} catch {
print("Error: \(error.localizedDescription)")
}
}
The async/await version is shorter, easier to read, and composes naturally with other async operations. Error handling uses a dedicated OGPeekError enum so callers get meaningful diagnostics instead of generic URLError codes.
Tip: The OGPeek API caches generated images at the CDN edge. The first request for a given set of parameters takes 200–400ms. Subsequent requests for the same URL return the cached image in under 50ms. You do not need to implement your own caching layer unless you want to persist images offline.
Vapor Route Handler for Dynamic OG Images
If you are building a web app with Vapor, you can serve dynamic OG images through your own routes. Social media crawlers hit your Vapor endpoint, which proxies the request to OGPeek and returns the PNG. This lets you keep your OG image URLs on your own domain:
import Vapor
func routes(_ app: Application) throws {
// Serve dynamic OG images via your own domain
// e.g., GET /og?title=My+Post&subtitle=Blog
app.get("og") { req async throws -> Response in
let title = try req.query.get(String.self, at: "title")
let subtitle = req.query[String.self, at: "subtitle"]
let params = OGPeekParams(
title: title,
subtitle: subtitle,
template: "gradient",
theme: "dark",
brandColor: "#F59E0B"
)
guard let ogURL = params.buildURL() else {
throw Abort(.badRequest, reason: "Invalid OG image parameters")
}
// Fetch the image from OGPeek
let clientResponse = try await req.client.get(URI(string: ogURL.absoluteString))
guard clientResponse.status == .ok,
let body = clientResponse.body else {
throw Abort(.badGateway, reason: "OGPeek API returned an error")
}
// Return PNG with proper headers
var headers = HTTPHeaders()
headers.add(name: .contentType, value: "image/png")
headers.add(name: .cacheControl, value: "public, max-age=86400, s-maxage=604800")
return Response(status: .ok, headers: headers, body: .init(buffer: body))
}
// Blog post page with dynamic og:image meta tag
app.get("blog", ":slug") { req async throws -> View in
guard let slug = req.parameters.get("slug") else {
throw Abort(.notFound)
}
// Fetch post from database
guard let post = try await BlogPost.query(on: req.db)
.filter(\.$slug == slug)
.first() else {
throw Abort(.notFound)
}
let params = OGPeekParams(
title: post.title,
subtitle: post.category
)
return try await req.view.render("blog/detail", [
"post": post,
"ogImageURL": params.buildURL()?.absoluteString ?? "",
])
}
}
The first route (/og) acts as a proxy: it receives the request, fetches the image from OGPeek, and returns it with aggressive cache headers. Social crawlers see your domain in the og:image URL, which is better for brand consistency. The second route shows how to inject the OGPeek URL into a Leaf template context for server-rendered HTML pages.
Direct URL vs. proxy: You do not have to proxy through Vapor. You can embed the OGPeek URL directly in your Leaf template’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.
Leaf Template Integration
If you prefer to embed the OGPeek URL directly in your Vapor/Leaf templates without proxying, pass the URL string from your route handler and use it in the template:
<!-- Resources/Views/blog/detail.leaf -->
<head>
<title>#(post.title) — My Vapor Blog</title>
<!-- Open Graph -->
<meta property="og:title" content="#(post.title)">
<meta property="og:description" content="#(post.excerpt)">
<meta property="og:type" content="article">
<meta property="og:image" content="#(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="#(post.title)">
<meta name="twitter:image" content="#(ogImageURL)">
</head>
This is the simplest approach. The Leaf template receives the pre-built URL and drops it into the meta tags. No image processing logic in the template, no URL construction in HTML. The route handler owns all the logic; the template just renders.
SwiftUI AsyncImage Preview Component
When building an iOS or macOS app that lets users create content with social sharing, you want to show them a preview of what the OG image will look like before they publish. SwiftUI's AsyncImage makes this trivial:
import SwiftUI
struct OGImagePreview: View {
let title: String
let subtitle: String
let template: String
let theme: String
let brandColor: String
private var imageURL: URL? {
OGPeekParams(
title: title,
subtitle: subtitle,
template: template,
theme: theme,
brandColor: brandColor
).buildURL()
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Social Preview")
.font(.headline)
.foregroundStyle(.secondary)
if let url = imageURL {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.aspectRatio(1200/630, contentMode: .fit)
.overlay {
ProgressView()
}
case .success(let image):
image
.resizable()
.aspectRatio(1200/630, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 8)
case .failure:
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.aspectRatio(1200/630, contentMode: .fit)
.overlay {
Label("Failed to load preview", systemImage: "exclamationmark.triangle")
.foregroundStyle(.secondary)
}
@unknown default:
EmptyView()
}
}
}
}
.padding()
}
}
// Usage in a content editor
struct PostEditorView: View {
@State private var postTitle = ""
@State private var postSubtitle = ""
var body: some View {
Form {
Section("Content") {
TextField("Title", text: $postTitle)
TextField("Subtitle", text: $postSubtitle)
}
Section("Preview") {
OGImagePreview(
title: postTitle.isEmpty ? "Untitled Post" : postTitle,
subtitle: postSubtitle,
template: "gradient",
theme: "dark",
brandColor: "#F59E0B"
)
}
}
}
}
The OGImagePreview component handles three states: loading (shows a progress spinner inside a placeholder rectangle), success (renders the image with proper aspect ratio and rounded corners), and failure (shows an error label). Because AsyncImage manages the network request internally, you get image caching for free through URLSession's default cache.
Debouncing: If you bind OGImagePreview directly to a text field, it will fire a new API request on every keystroke. In production, debounce the title input by 500ms before updating the preview. Use Combine's .debounce(for: .milliseconds(500), scheduler: RunLoop.main) or a simple Task with try await Task.sleep(for: .milliseconds(500)) to avoid hammering the API while the user types.
Batch Generation with TaskGroup
When you need to generate OG images for many items at once—migrating a blog to new preview images, pre-warming the CDN cache, or generating images for a product catalog—Swift's TaskGroup lets you run requests concurrently with controlled parallelism:
import Foundation
struct OGImageResult {
let title: String
let imageData: Data
}
func generateOGImagesBatch(
items: [(title: String, subtitle: String)],
maxConcurrency: Int = 5
) async throws -> [OGImageResult] {
try await withThrowingTaskGroup(of: OGImageResult?.self) { group in
var results: [OGImageResult] = []
var index = 0
// Seed the group with initial tasks up to maxConcurrency
for _ in 0..<min(maxConcurrency, items.count) {
let item = items[index]
group.addTask {
try await generateSingleImage(title: item.title, subtitle: item.subtitle)
}
index += 1
}
// As each task completes, add the next one
for try await result in group {
if let result = result {
results.append(result)
}
if index < items.count {
let item = items[index]
group.addTask {
try await generateSingleImage(title: item.title, subtitle: item.subtitle)
}
index += 1
}
}
return results
}
}
private func generateSingleImage(title: String, subtitle: String) async throws -> OGImageResult? {
let params = OGPeekParams(title: title, subtitle: subtitle)
guard let url = params.buildURL() else { return nil }
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return nil
}
return OGImageResult(title: title, imageData: data)
}
// Usage: generate OG images for all blog posts
Task {
let posts: [(title: String, subtitle: String)] = [
("Understanding Swift Concurrency", "Swift 5.5+"),
("Building REST APIs with Vapor", "Server-Side Swift"),
("SwiftUI State Management", "iOS Development"),
("Core Data vs SwiftData", "Data Persistence"),
("Combine Framework Deep Dive", "Reactive Programming"),
]
let results = try await generateOGImagesBatch(items: posts, maxConcurrency: 3)
print("Generated \(results.count) OG images")
// Save images to disk or upload to storage
for result in results {
let filename = result.title
.lowercased()
.replacingOccurrences(of: " ", with: "-") + ".png"
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
try result.imageData.write(to: fileURL)
print("Saved: \(fileURL.path)")
}
}
The maxConcurrency parameter 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.
Building an OGPeek Swift Package
If you use OGPeek across multiple Swift projects, wrap the functionality in a reusable Swift Package. Create a package with a single OGPeek module that exposes the OGPeekParams struct and the fetch functions. The package has zero external dependencies since everything uses Foundation's URLSession and URLComponents:
// Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "OGPeekSwift",
platforms: [.macOS(.v12), .iOS(.v15)],
products: [
.library(name: "OGPeek", targets: ["OGPeek"]),
],
targets: [
.target(name: "OGPeek"),
.testTarget(name: "OGPeekTests", dependencies: ["OGPeek"]),
]
)
Drop the OGPeekParams struct and the async fetch function into Sources/OGPeek/, and any project can add it as a dependency with a single line in their Package.swift. No CocoaPods, no Carthage—just Swift Package Manager.
Pricing and Rate Limits
OGPeek offers three tiers to match your usage:
- Free: 50 images per day. No API key required. Images include a small watermark. Perfect for prototyping and personal projects.
- Starter ($9/month): 10,000 images per month. No watermark. Priority CDN caching. Ideal for blogs, portfolios, and small SaaS apps.
- Pro ($29/month): 50,000 images per month. No watermark. Custom templates. Webhook notifications. Built for high-traffic platforms and agencies.
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 Swift app in minutes. No signup required.
Try OGPeek free →Wrapping Up
Swift gives you multiple clean integration points for OGPeek depending on where you are in the stack:
- iOS/macOS apps: Use the
OGPeekParamsstruct to build URLs andURLSessionwith async/await to fetch images. Show previews with SwiftUI'sAsyncImagebefore users share content. - Vapor web apps: Embed the OGPeek URL directly in Leaf templates, or proxy through your own route for domain consistency and custom cache headers.
- CLI tools and scripts: Use
TaskGroupfor batch generation when migrating content or pre-warming the CDN cache. - Swift Packages: Wrap the functionality in a reusable package with zero external dependencies for consistent OG image generation across all your projects.
The underlying principle is the same regardless of which pattern you choose: build a URL with URLComponents, point it at the OGPeek API, and let the CDN handle image generation and caching. Your Swift code does zero image processing, imports zero 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 Django + OGPeek, Express.js + OGPeek, or Rails + OGPeek.