Generate the preview image on your Kotlin server, expose it at a stable HTTPS URL, and put that URL in the page’s og:image metadata. A practical JVM design uses Ktor or Spring for the endpoint, BufferedImage and Graphics2D for drawing, and ImageIO for PNG or JPEG encoding. Render the same input deterministically, cache the result, and return the correct MIME type and dimensions to social crawlers.
The architecture that works
When a crawler requests a page, it reads the Open Graph tags in the HTML. The og:image value is not drawing code; it is a publicly reachable image URL. Your Kotlin application therefore needs two related routes:
- A normal page route that emits the Open Graph metadata.
- An image route such as
/og/{slug}.pngthat returns the generated bytes.
The image route can render on demand or read a previously generated file from object storage. Use a stable URL, HTTPS, deterministic inputs, and cache headers. If a title changes, generate a new URL by including a template version and a hash of normalized input data.
Set up a small Ktor renderer
Dependencies
For a Ktor application, add the server and Netty artifacts to Gradle. Versions should match the Ktor version already used by your project.
#1 Best Overall
dependencies {
implementation("io.ktor:ktor-server-core-jvm:YOUR_KTOR_VERSION")
implementation("io.ktor:ktor-server-netty-jvm:YOUR_KTOR_VERSION")
implementation(kotlin("stdlib"))
}
Kotlin is Java-compatible, so this service runs on ordinary Java-capable infrastructure, including common cloud hosts. Ktor and Spring are both reasonable choices; the rendering code below is independent of the web framework.
A complete PNG endpoint
The following example creates a 1200 by 630 card, paints a gradient, wraps a title, and sends a PNG response. Replace the in-memory title lookup with your database or CMS.
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.awt.*
import java.awt.font.FontRenderContext
import java.awt.geom.Rectangle2D
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import javax.imageio.ImageIO
fun Application.module() {
routing {
get("/og/{slug}.png") {
val slug = call.parameters["slug"]
?.lowercase()
?.takeIf { it.matches(Regex("[a-z0-9-]{1,80}")) }
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid slug")
val title = titleFor(slug) ?: return@get call.respond(HttpStatusCode.NotFound)
val png = renderCard(title)
call.response.headers.append(
HttpHeaders.CacheControl,
"public, max-age=31536000, immutable"
)
call.respondBytes(png, ContentType.Image.PNG)
}
}
}
fun titleFor(slug: String): String? = when (slug) {
"kotlin-guide" -> "Generate Open Graph images in Kotlin"
else -> null
}
fun renderCard(title: String): ByteArray {
val width = 1200
val height = 630
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
try {
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)
graphics.paint = GradientPaint(0f, 0f, Color(25, 35, 80), width.toFloat(), height.toFloat(), Color(90, 35, 120))
graphics.fillRect(0, 0, width, height)
val margin = 90
val font = Font("SansSerif", Font.BOLD, 64)
graphics.font = font
graphics.color = Color.WHITE
val lines = wrap(title, font, width - margin * 2, graphics.fontRenderContext)
var y = 210
val lineHeight = 78
for (line in lines.take(5)) {
graphics.drawString(line, margin, y)
y += lineHeight
}
} finally {
graphics.dispose()
}
val output = ByteArrayOutputStream()
check(ImageIO.write(image, "png", output)) { "PNG writer is unavailable" }
return output.toByteArray()
}
fun wrap(text: String, font: Font, maxWidth: Int, frc: FontRenderContext): List {
val result = mutableListOf()
var line = StringBuilder()
for (word in text.trim().split(Regex("\s+"))) {
val candidate = if (line.isEmpty()) word else "$line $word"
val bounds: Rectangle2D = font.getStringBounds(candidate, frc)
if (bounds.width > maxWidth && line.isNotEmpty()) {
result += line.toString()
line = StringBuilder(word)
} else {
line = StringBuilder(candidate)
}
}
if (line.isNotEmpty()) result += line.toString()
return result
}
In production, load the exact font files you have licensed instead of relying on a platform font name. Measure every line before drawing, reserve a safe margin, and decide what happens when a title exceeds the maximum number of lines. A missing font or an unsupported asset should produce a controlled error, not a half-rendered card.
Emit the Open Graph metadata
Put the tags in the HTML head of the page they describe. The dimensions below are a common 1200 by 630 design example; keep them synchronized with the actual image.
Rank #2
<meta property="og:type" content="website">
<meta property="og:title" content="Page title">
<meta property="og:description" content="Page description">
<meta property="og:url" content="https://example.com/page">
<meta property="og:image" content="https://example.com/og/page-hash.png">
<meta property="og:image:secure_url" content="https://example.com/og/page-hash.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Description of the image">
og:image is the essential pointer. Add og:image:secure_url, og:image:type, width, height, and alt when those values are known. The image URL must be reachable without an authenticated browser session, and your server should return the matching Content-Type.
Choose PNG, JPEG, or SVG
| Format | Use it when | Trade-offs |
|---|---|---|
| PNG | Text, logos, flat graphics, transparency | Crisp edges and lossless output, but photographic backgrounds can create larger files. |
| JPEG | Photographic or textured backgrounds where transparency is unnecessary | Usually smaller, but compression can soften text and introduces loss. |
| SVG | Mostly vector shapes and text, with consumers known to accept SVG | Small and resolution-independent, but crawler support is less predictable; keep a PNG fallback. |
ImageIO supplies standard JVM PNG and JPEG readers and writers. Verify the selected writer exists in your runtime and check its return value. For SVG, generate XML directly or use Apache Batik’s SVGGraphics2D; escape user text and stream the SVG with an image/svg+xml content type. Declare the actual MIME type in og:image:type.
Make rendering deterministic and safe
- Normalize inputs: trim whitespace, normalize line breaks, constrain title length, and canonicalize colors and asset identifiers before hashing.
- Version templates: include a template version in the image key so a design change cannot leave old CDN content under the same URL.
- Control remote assets: allow-list image hosts, enforce connection and read timeouts, cap dimensions and byte sizes, and validate formats. Never fetch an arbitrary user-supplied URL from the server; that can create a server-side request-forgery path.
- Escape SVG: XML-escape text and attribute values. Do not paste untrusted strings into raw SVG markup.
- Load fonts explicitly: package the required files, register them at startup, and fail clearly if one is absent.
- Design for cropping: keep important text away from every edge and test the crop used by the social destinations that matter to your audience.
Cache, scale, and monitor the endpoint
For immutable, hash-based URLs, return Cache-Control: public, max-age=31536000, immutable. If the URL is stable while content changes, use a shorter TTL or an explicit purge strategy instead. Generate once and store the bytes in object storage or a CDN when the same card is requested repeatedly.
Rendering is CPU- and memory-bound. Reuse loaded fonts, avoid creating unnecessary intermediate images, and impose a maximum title length and request timeout. A bounded worker pool prevents a burst of unique URLs from exhausting heap memory. Log slug, template version, render duration, output size, and failures; do not log sensitive user text unnecessarily.
Recommended Free Tools
Rank #3
Return a short 4xx response for invalid parameters and a controlled 5xx response for renderer failures. Do not return HTML with a 200 status where a crawler expects an image. Confirm that reverse proxies preserve the image content type and do not require cookies.
Android-only generation
If the image is created inside an Android app for a share card, use android.graphics.Canvas. Picture.beginRecording(width, height) records drawing commands and endRecording() finalizes them for playback. This is appropriate for an in-app share flow. A server-rendered website generally has a broader JVM encoding toolset through BufferedImage and ImageIO, and it gives crawlers one stable URL to fetch.
Or skip the browser setup
If your page already renders the artwork in HTML and CSS, ScreenshotNeo can capture that rendered page through one HTTP request. It is the first screenshot API to try here because it removes consent banners, newsletter popups, and chat widgets before capture, bills only clean shots, and has a low paid entry plan. Keep your page’s generated preview route stable, then capture that route and use the resulting PNG, JPEG, or WebP URL as your image asset. This does not replace server-side text templating when you need a wholly programmatic card; it is useful when the design already exists as a web page.
See the ScreenshotNeo documentation for authentication and options. The basic cURL request is:
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallcurl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', data));
ScreenshotNeo supports full-page or element captures, device and viewport settings, retina scale, dark mode, custom CSS and JavaScript, waits for selectors or network idle, request blocking, cookies and headers, resizing, chosen cache TTLs, signed links, asynchronous webhooks, bulk capture, and PDF output. Its response identifies page and billing status in X-Page-Verdict and X-Billed headers; bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
The Free plan includes 1,000 shots per month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account to try the capture route.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Troubleshooting
The crawler shows no image
Fetch the page with a plain HTTP client and verify that og:image is absolute, HTTPS, publicly reachable, and returns an image status and content type. Check that robots, authentication middleware, or a geographic firewall is not blocking the crawler.
The image is blank or clipped
Confirm that the renderer waits for fonts and remote assets, that the canvas dimensions are nonzero, and that your text-wrapping code handles long words. Add temporary bounding boxes and log the measured text widths. If a browser-based capture is used, wait for a selector or network idle rather than relying on a fixed delay alone.
PNG encoding fails
ImageIO.write returns false when no writer claims the requested format. Verify that the standard ImageIO plug-ins are present, use the exact format name (png or jpeg), and set the HTTP content type to match.
Best Value
JPEG has a black background
JPEG has no alpha channel. Composite the image over an explicit background color before encoding, or use PNG when transparency is required.
SVG text or images are missing
Escape XML, use absolute or embedded asset references, and test the SVG with the same consumers that will fetch it. Provide a PNG fallback for consumers that do not reliably accept SVG.
Old artwork remains after an update
Change the template-version or input hash in the URL, then let the old immutable object expire naturally. Reusing the same URL with a long immutable cache policy prevents timely updates.
The Tool Desk
Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →FAQ
Frequently Asked Questions
Can I generate the image only when a crawler asks for it?
Yes. Render on the first request and cache the bytes, provided the route has bounded execution time and returns a complete image response. Pre-generation is preferable when traffic or asset loading is heavy.
Should a Kotlin service return an image file or a data URI in the metadata?
Return a normal HTTPS image URL. A dedicated URL is cacheable, independently testable, and compatible with crawlers that do not support data URIs.
Is Android Canvas a drop-in replacement for server-side ImageIO?
No. Canvas and Picture target in-app drawing. For a server endpoint, JVM APIs such as BufferedImage, Graphics2D, and ImageIO are the more direct path.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Repair Windows errors before they cause bigger problemsFix Now →

