Generate Dynamic OG Images in Kotlin with OGPeek API
Kotlin developers building backends with Ktor or Spring Boot, Android apps with Jetpack Compose, or Kotlin Multiplatform projects that publish content all share the same problem: generating social preview images is unnecessarily complex. Server-side rendering with headless Chrome is slow and resource-heavy. Drawing images with java.awt.Graphics2D is verbose and fragile. OGPeek eliminates all of that. Construct a URL with your title and branding, call it with OkHttp or Ktor’s HttpClient, and get back a 1200×630 PNG. This guide covers every Kotlin integration pattern—from data classes and OkHttp calls to Ktor route handlers, Spring Boot controllers, Jetpack Compose previews, and concurrent batch generation with coroutines.
Why Kotlin 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 disappears 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 a Ktor web app, a Spring Boot service, or an Android 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 JVM ecosystem all have significant downsides:
- java.awt.Graphics2D requires manual text layout with
FontMetrics, coordinate math, anti-aliasing configuration, and 100–200 lines of drawing code per layout. The API is from the Java 1.1 era and feels it. - Headless Chrome via Selenium or Playwright requires running a full browser process on your server. That means 200MB+ of RAM per instance, cold-start times of several seconds, and a heavy dependency that complicates CI/CD and container images.
- Static images mean every shared link gets the same generic preview. A blog post about coroutines and a blog post about Ktor routing look identical when shared—and neither gets clicked.
- imgproxy or Puppeteer microservices add operational overhead: another service to deploy, monitor, scale, and pay for. You are running infrastructure to draw text on a rectangle.
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 Kotlin 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+Kotlin+App
&subtitle=Built+with+Ktor
&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.
Kotlin Data Class for API Parameters
Start by defining a clean data class that encapsulates the OGPeek API parameters. This gives you immutability, copy semantics, and a single place to manage defaults:
import java.net.URLEncoder
data class OGPeekParams(
val title: String,
val subtitle: String? = null,
val template: String = "gradient",
val theme: String = "dark",
val brandColor: String = "#F59E0B"
) {
companion object {
const val BASE_URL =
"https://todd-agent-prod.web.app/api/v1/og"
}
/** Build the full OGPeek URL with query parameters. */
fun buildUrl(): String = buildString {
append(BASE_URL)
append("?title=").append(encode(title.take(60)))
append("&template=").append(encode(template))
append("&theme=").append(encode(theme))
append("&brandColor=").append(encode(brandColor))
subtitle?.let { append("&subtitle=").append(encode(it.take(80))) }
}
private fun encode(value: String): String =
URLEncoder.encode(value, Charsets.UTF_8.name())
}
Using URLEncoder.encode handles all percent-encoding automatically. The # in brandColor becomes %23 in the final URL without any extra work. The data class gives you copy() for free, so you can derive variants easily: params.copy(theme = "midnight").
OkHttp Client — Synchronous Request
OkHttp is the most widely used HTTP client in the Kotlin ecosystem, and it works on both JVM and Android. Here is a straightforward synchronous call that fetches the generated OG image as raw bytes:
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
class OGPeekClient(
private val httpClient: OkHttpClient = OkHttpClient()
) {
/** Fetch the OG image synchronously. Returns PNG bytes. */
@Throws(IOException::class)
fun fetchImage(params: OGPeekParams): ByteArray {
val request = Request.Builder()
.url(params.buildUrl())
.header("Accept", "image/png")
.get()
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("OGPeek API returned ${response.code}")
}
return response.body?.bytes()
?: throw IOException("Empty response body")
}
}
}
// Usage
fun main() {
val client = OGPeekClient()
val params = OGPeekParams(
title = "Building REST APIs with Ktor",
subtitle = "Kotlin Server-Side"
)
val imageBytes = client.fetchImage(params)
println("Downloaded ${imageBytes.size} bytes")
// Save to disk
java.io.File("og-preview.png").writeBytes(imageBytes)
}
The .use { } block ensures the response body is closed even if an exception occurs. This is important because OkHttp response bodies are single-use resources that hold a connection to the server until closed.
OkHttp with Coroutines — Async Request
For non-blocking code in Android apps or coroutine-based backends, wrap the OkHttp call in a suspendCancellableCoroutine to bridge between OkHttp’s callback API and Kotlin coroutines:
import kotlinx.coroutines.*
import okhttp3.*
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** Suspend extension that bridges OkHttp's async Call to coroutines. */
suspend fun OGPeekClient.fetchImageAsync(
params: OGPeekParams
): ByteArray = suspendCancellableCoroutine { continuation ->
val request = Request.Builder()
.url(params.buildUrl())
.header("Accept", "image/png")
.get()
.build()
val call = OkHttpClient().newCall(request)
continuation.invokeOnCancellation { call.cancel() }
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!it.isSuccessful) {
continuation.resumeWithException(
IOException("OGPeek API returned ${it.code}")
)
return
}
val bytes = it.body?.bytes()
if (bytes != null) {
continuation.resume(bytes)
} else {
continuation.resumeWithException(
IOException("Empty response body")
)
}
}
}
})
}
// Usage with coroutines
suspend fun main() {
val client = OGPeekClient()
val params = OGPeekParams(
title = "Kotlin Coroutines Deep Dive",
subtitle = "Structured Concurrency"
)
val imageBytes = client.fetchImageAsync(params)
println("Downloaded ${imageBytes.size} bytes")
}
The suspendCancellableCoroutine bridge is the idiomatic way to integrate callback-based APIs with structured concurrency. When the coroutine is cancelled (e.g., the user navigates away in an Android app), the OkHttp call is cancelled too, preventing wasted network requests and memory leaks.
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.
Ktor Server Route Handler
If you are building a web app with Ktor, you can serve dynamic OG images through your own routes. Social media crawlers hit your Ktor endpoint, which proxies the request to OGPeek and returns the PNG. This lets you keep your OG image URLs on your own domain:
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureOGRoutes() {
val client = HttpClient()
routing {
// Proxy OG image through your domain
// e.g., GET /og?title=My+Post&subtitle=Blog
get("/og") {
val title = call.request.queryParameters["title"]
?: return@get call.respondText(
"Missing title parameter",
status = HttpStatusCode.BadRequest
)
val subtitle = call.request.queryParameters["subtitle"]
val params = OGPeekParams(
title = title,
subtitle = subtitle,
template = "gradient",
theme = "dark",
brandColor = "#F59E0B"
)
val response = client.get(params.buildUrl())
if (response.status != HttpStatusCode.OK) {
call.respondText(
"OGPeek API error",
status = HttpStatusCode.BadGateway
)
return@get
}
val imageBytes = response.readBytes()
call.response.headers.append(
HttpHeaders.CacheControl,
"public, max-age=86400, s-maxage=604800"
)
call.respondBytes(imageBytes, ContentType.Image.PNG)
}
// Blog post page with dynamic og:image meta tag
get("/blog/{slug}") {
val slug = call.parameters["slug"]
?: return@get call.respondText("Not found", status = HttpStatusCode.NotFound)
// Fetch post from database
val post = PostRepository.findBySlug(slug)
?: return@get call.respondText("Not found", status = HttpStatusCode.NotFound)
val ogImageUrl = OGPeekParams(
title = post.title,
subtitle = post.category
).buildUrl()
call.respondHtml {
head {
meta(name = "og:image", content = ogImageUrl)
meta(name = "og:image:width", content = "1200")
meta(name = "og:image:height", content = "630")
}
}
}
}
}
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 your HTML response for server-rendered pages.
Direct URL vs. proxy: You do not have to proxy through Ktor. You can embed the OGPeek URL directly in your HTML 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.
Spring Boot Controller
For Spring Boot applications, create a @RestController that generates OG image URLs from your model data. This approach works seamlessly with Thymeleaf, FreeMarker, or any other template engine:
import org.springframework.http.*
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
import org.springframework.web.client.RestTemplate
@Controller
class BlogController(
private val postRepository: PostRepository,
private val restTemplate: RestTemplate = RestTemplate()
) {
/** Render blog post with dynamic OG image URL in the template. */
@GetMapping("/blog/{slug}")
fun blogPost(@PathVariable slug: String, model: Model): String {
val post = postRepository.findBySlug(slug)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
val ogImageUrl = OGPeekParams(
title = post.title,
subtitle = "${post.author} · ${post.readTime} min read"
).buildUrl()
model.addAttribute("post", post)
model.addAttribute("ogImageUrl", ogImageUrl)
return "blog/detail"
}
/** Proxy endpoint: serve OG images from your own domain. */
@GetMapping("/og", produces = [MediaType.IMAGE_PNG_VALUE])
@ResponseBody
fun ogImage(
@RequestParam title: String,
@RequestParam(required = false) subtitle: String?
): ResponseEntity<ByteArray> {
val params = OGPeekParams(title = title, subtitle = subtitle)
val imageBytes = restTemplate.getForObject(
params.buildUrl(), ByteArray::class.java
) ?: return ResponseEntity.badGateway().build()
return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=86400, s-maxage=604800")
.contentType(MediaType.IMAGE_PNG)
.body(imageBytes)
}
}
In your Thymeleaf template, use the ogImageUrl model attribute to set the meta tag:
<!-- templates/blog/detail.html -->
<head>
<title th:text="${post.title} + ' — My Blog'">Blog Post</title>
<!-- Open Graph -->
<meta property="og:title" th:attr="content=${post.title}">
<meta property="og:description" th:attr="content=${post.excerpt}">
<meta property="og:type" content="article">
<meta property="og:image" th:attr="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" th:attr="content=${post.title}">
<meta name="twitter:image" th:attr="content=${ogImageUrl}">
</head>
The controller builds the URL, the template renders it. No image processing logic in the template, no URL construction in HTML. The controller owns all the logic; the template just renders.
Kotlin Multiplatform Considerations
If you are building a Kotlin Multiplatform (KMP) project that targets JVM, Android, iOS, and potentially JavaScript, the OGPeekParams data class works everywhere because it only uses String operations. The HTTP client is where platform differences appear:
// shared/src/commonMain/kotlin/OGPeekApi.kt
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
/** Cross-platform OGPeek client using Ktor HttpClient. */
class OGPeekApi(private val client: HttpClient = HttpClient()) {
/** Fetch OG image bytes. Works on JVM, Android, iOS, and JS. */
suspend fun fetchImage(params: OGPeekParams): ByteArray {
val response = client.get(params.buildUrl())
check(response.status == HttpStatusCode.OK) {
"OGPeek API returned ${response.status}"
}
return response.readBytes()
}
/** Just build the URL — useful when you only need the meta tag value. */
fun imageUrl(params: OGPeekParams): String = params.buildUrl()
}
// Use in commonMain without platform-specific code
suspend fun generatePreview(title: String): ByteArray {
val api = OGPeekApi()
return api.fetchImage(OGPeekParams(title = title))
}
Ktor’s HttpClient is the natural choice for KMP because it provides a common API with platform-specific engines underneath: OkHttp on Android, Darwin (URLSession) on iOS, and CIO or Java on server JVM. You write the code once in commonMain, and each platform uses its native networking stack. The OGPeekParams data class needs no changes at all—just replace java.net.URLEncoder with Ktor’s encodeURLParameter() extension for full multiplatform compatibility:
// Multiplatform-safe URL encoding
import io.ktor.http.*
fun OGPeekParams.buildUrlKmp(): String = buildString {
append(OGPeekParams.BASE_URL)
append("?title=").append(title.take(60).encodeURLParameter())
append("&template=").append(template.encodeURLParameter())
append("&theme=").append(theme.encodeURLParameter())
append("&brandColor=").append(brandColor.encodeURLParameter())
subtitle?.let { append("&subtitle=").append(it.take(80).encodeURLParameter()) }
}
Coroutines — Async 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, or generating images for a product catalog—Kotlin coroutines with controlled concurrency make this clean and efficient:
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
data class OGImageResult(
val title: String,
val imageData: ByteArray
)
/**
* Generate OG images for a list of items with controlled concurrency.
* The semaphore limits how many requests run in parallel.
*/
suspend fun generateOGImagesBatch(
items: List<Pair<String, String>>, // title to subtitle
maxConcurrency: Int = 5
): List<OGImageResult> = coroutineScope {
val semaphore = Semaphore(maxConcurrency)
val client = OGPeekClient()
items.map { (title, subtitle) ->
async {
semaphore.withPermit {
try {
val params = OGPeekParams(title = title, subtitle = subtitle)
val bytes = client.fetchImage(params)
OGImageResult(title, bytes)
} catch (e: Exception) {
println("Failed to generate image for '$title': ${e.message}")
null
}
}
}
}
.awaitAll()
.filterNotNull()
}
// Usage
suspend fun main() {
val posts = listOf(
"Understanding Kotlin Coroutines" to "Structured Concurrency",
"Building REST APIs with Ktor" to "Server-Side Kotlin",
"Jetpack Compose State Management" to "Android Development",
"Kotlin Multiplatform in Production" to "Cross-Platform",
"Gradle Kotlin DSL Deep Dive" to "Build Systems",
)
val results = generateOGImagesBatch(posts, maxConcurrency = 3)
println("Generated ${results.size} OG images")
// Save images to disk
results.forEach { result ->
val filename = result.title
.lowercase()
.replace(" ", "-") + ".png"
java.io.File(filename).writeBytes(result.imageData)
println("Saved: $filename")
}
}
The Semaphore 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.
Jetpack Compose Preview Component
When building an Android 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. Jetpack Compose with Coil makes this straightforward:
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
@Composable
fun OGImagePreview(
title: String,
subtitle: String,
template: String = "gradient",
theme: String = "dark",
brandColor: String = "#F59E0B",
modifier: Modifier = Modifier
) {
val imageUrl = remember(title, subtitle, template, theme, brandColor) {
OGPeekParams(
title = title,
subtitle = subtitle,
template = template,
theme = theme,
brandColor = brandColor
).buildUrl()
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Social Preview",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
SubcomposeAsyncImage(
model = imageUrl,
contentDescription = "OG image preview",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1200f / 630f)
.clip(RoundedCornerShape(12.dp)),
loading = {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(strokeWidth = 2.dp)
}
},
error = {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"Failed to load preview",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
)
}
}
// Usage in a content editor screen
@Composable
fun PostEditorScreen() {
var postTitle by remember { mutableStateOf("") }
var postSubtitle by remember { mutableStateOf("") }
// Debounce the title to avoid API calls on every keystroke
val debouncedTitle by produceState(
initialValue = "Untitled Post",
key1 = postTitle
) {
delay(500) // wait 500ms after last keystroke
value = postTitle.ifBlank { "Untitled Post" }
}
Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = postTitle,
onValueChange = { postTitle = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = postSubtitle,
onValueChange = { postSubtitle = it },
label = { Text("Subtitle") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
OGImagePreview(
title = debouncedTitle,
subtitle = postSubtitle
)
}
}
The OGImagePreview composable handles three states: loading (shows a circular progress indicator), success (renders the image at the correct 1200:630 aspect ratio with rounded corners), and error (shows an error message). The remember key ensures the URL is only recomputed when the parameters actually change. Coil handles image caching automatically through its built-in memory and disk cache.
Debouncing: The PostEditorScreen example uses produceState with a 500ms delay to debounce the title input. Without this, every keystroke would trigger a new API request. The debounced value only updates after the user stops typing for half a second, keeping API usage efficient and the UI responsive.
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 Kotlin app in minutes. No signup required.
Try OGPeek free →Wrapping Up
Kotlin gives you multiple clean integration points for OGPeek depending on where you are in the stack:
- Android apps: Use the
OGPeekParamsdata class to build URLs and Coil’sSubcomposeAsyncImagein Jetpack Compose to show live previews. Debounce user input withproduceStateto keep API calls efficient. - Ktor web apps: Embed the OGPeek URL directly in HTML responses, or proxy through your own route for domain consistency and custom cache headers.
- Spring Boot services: Build the URL in your controller, pass it to your Thymeleaf template, and optionally expose a proxy endpoint for on-domain image serving.
- Kotlin Multiplatform: Use Ktor’s
HttpClientincommonMainfor a single codebase that generates OG images on JVM, Android, iOS, and JavaScript targets. - Batch processing: Use coroutines with a
Semaphorefor controlled concurrent generation when migrating content or pre-warming the CDN cache.
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 Kotlin 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 Swift + OGPeek, Django + OGPeek, or Express.js + OGPeek.