Swift Guide
Published March 29, 2026 · 10 min read

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.

OG image preview for this article generated by OGPeek

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:

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:

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:

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:

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.