Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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}.png that 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -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.Support on Ko-Fi

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.