How to generate Open Graph images from HTML
Design a 1200 by 630 share card in HTML and CSS, render it to an image with one POST request, and use it as your og:image.
Send a piece of HTML, get back an image of it. This request makes a share card that is 1200 by 630 pixels:
curl -X POST "https://curlshot.com/api/v1/screenshot" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<body style=\"margin:0;width:1200px;height:630px;display:flex;flex-direction:column;justify-content:center;padding:80px;box-sizing:border-box;background:#0f172a;color:#fff;font-family:sans-serif\"><div style=\"font-size:28px;color:#38bdf8\">your-app.example</div><h1 style=\"font-size:72px;line-height:1.1;margin:24px 0 0\">How we cut our build time in half</h1></body>",
"viewport_width": 1200,
"viewport_height": 630,
"format": "png"
}' \
--output og-card.pngYou get og-card.png: a dark card with a small site name and a large title.

An Open Graph image is the image that appears when someone shares a link in a chat app or on a social network. Open Graph is the name of the set of <meta> tags that those apps read.
#1. Design the card in HTML and CSS
Make a normal HTML file and open it in your browser. Design it the way you would design any small web page.
The usual size for a share image is 1200 by 630 pixels. Fix the body to that size, so what you see in the browser is what you get.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
margin: 0;
width: 1200px;
height: 630px;
box-sizing: border-box;
padding: 80px;
display: flex;
flex-direction: column;
justify-content: center;
background: linear-gradient(135deg, #0f172a, #1e3a8a);
color: #fff;
font-family: system-ui, sans-serif;
}
.site { font-size: 28px; color: #38bdf8; }
h1 { font-size: 72px; line-height: 1.1; margin: 24px 0 0; }
.author { font-size: 30px; margin-top: 40px; opacity: 0.8; }
</style>
</head>
<body>
<div class="site">your-app.example</div>
<h1>How we cut our build time in half</h1>
<div class="author">By Ada Example</div>
</body>
</html>A few things to know while you design:
- Everything must be inside the HTML or reachable on the public internet. Use full
https://addresses for images and fonts. - Keep text large. A share card is often shown small, and thin text gets hard to read.
- The HTML you send can be up to 2 MB.
#2. Send the HTML with POST
HTML is long and full of quotes, so it does not fit well in a query string. Send it as a POST request with a JSON body.
Set the viewport to the size of the card. The viewport is the browser window the page is drawn in, and the capture has the same size.
Writing HTML inside JSON by hand means escaping every quote. Let a tool do it. Here jq reads card.html and builds the body:
jq -n --rawfile html card.html \
'{html: $html, viewport_width: 1200, viewport_height: 630, format: "png"}' \
| curl -X POST "https://curlshot.com/api/v1/screenshot" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d @- \
--output og-card.pngThe result is og-card.png, exactly 1200 by 630 pixels, showing the card from step 1.
Two options matter here:
viewport_widthandviewport_height. The defaults are1280and1024, which is the wrong shape for a card. Set them to1200and630.format. The default ispng. PNG keeps text sharp. Usejpegif your card has a photo in it and the file gets large.
More on this input is on the HTML and Markdown input page.
#3. Fill in the title for each page
One design, many pages. Turn the HTML into a template and put in the title of each page with code.
import { writeFile } from 'node:fs/promises'
// Text from your database must be escaped before it goes into HTML.
const escapeHtml = (text) =>
text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
const cardHtml = ({ title, author }) => `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; width: 1200px; height: 630px; box-sizing: border-box; padding: 80px;
display: flex; flex-direction: column; justify-content: center;
background: linear-gradient(135deg, #0f172a, #1e3a8a);
color: #fff; font-family: system-ui, sans-serif; }
.site { font-size: 28px; color: #38bdf8; }
h1 { font-size: 72px; line-height: 1.1; margin: 24px 0 0; }
.author { font-size: 30px; margin-top: 40px; opacity: 0.8; }
</style>
</head>
<body>
<div class="site">your-app.example</div>
<h1>${escapeHtml(title)}</h1>
<div class="author">By ${escapeHtml(author)}</div>
</body>
</html>`
export async function renderCard(post) {
const response = await fetch('https://curlshot.com/api/v1/screenshot', {
method: 'POST',
headers: {
'X-Access-Key': process.env.SCREENSHOT_ACCESS_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html: cardHtml(post),
viewport_width: 1200,
viewport_height: 630,
format: 'png',
cache: true,
cache_key: 'card-v1',
}),
})
if (!response.ok) throw new Error((await response.json()).error_message)
return Buffer.from(await response.arrayBuffer())
}
const image = await renderCard({ title: 'How we cut our build time in half', author: 'Ada Example' })
await writeFile('og-card.png', image)Run it and you get the same og-card.png, now built from data.
#4. Cache the card with a cache_key
The sample in step 3 already sends cache: true and a cache_key. Here is what they do.
With cache on, the result is stored. If the same request comes in again, you get the stored copy. That is free and fast.
"The same request" includes the HTML. So the same title gives a hit, and a changed title gives a new card. You do not have to track that yourself.
The cache_key is a label of your choice that is stored with the copy. It helps in one case: the HTML is the same, but the image should change. That happens when you swap a logo file or a font that the HTML only links to.
Use a version label such as card-v1. When the linked files change, switch to card-v2, and every card is rendered fresh the next time it is asked for.
Check the X-Cache response header to see which one you got. MISS is a new render. HIT is a stored copy.
#5. Save the image and serve it from your own site
Do not point your og:image tag at the API. Render the card once, save the file, and serve it yourself.
There are two good reasons:
- A
POSTrequest cannot be written as a plain link, and anog:imagemust be a plain link. - Files that we store for you are removed after a while. With
response_type=json, theexpires_atfield tells you when, and theurlin that answer stops working at the same moment.
So do this when a post is published or edited:
- Call
renderCardfrom step 3. - Save the bytes where your site keeps its images, under a name that changes when the content changes. For example
og/build-time-3f9a1c.png, where the last part is a short hash of the title. - Store that path with the post.
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
// renderCard is the function from step 3.
export async function saveCard(post) {
const image = await renderCard(post)
const hash = createHash('sha256').update(post.title + post.author).digest('hex').slice(0, 6)
const path = `og/${post.slug}-${hash}.png`
await mkdir('public/og', { recursive: true })
await writeFile(`public/${path}`, image)
return path // save this with the post
}A new name for new content matters. Social networks keep their own copy of your image for a long time. A new file name is the reliable way to make them fetch the new card.
#6. Add the meta tags
Put these tags in the <head> of the page the card belongs to. The image address must be a full address, starting with https://.
<meta property="og:title" content="How we cut our build time in half">
<meta property="og:image" content="https://your-app.example/og/build-time-3f9a1c.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="How we cut our build time in half, by Ada Example">
<meta name="twitter:card" content="summary_large_image">The last tag asks for the large card layout on networks that read Twitter-style tags. Share the page in a private chat with yourself to see the card.
#7. Make it sharper, if you need to
On dense screens a 1200 pixel card can look a little soft. Add device_scale_factor to draw the same design with more pixels.
The default is 1.
jq -n --rawfile html card.html \
'{html: $html, viewport_width: 1200, viewport_height: 630, device_scale_factor: 2}' \
| curl -X POST "https://curlshot.com/api/v1/screenshot" \
-H "X-Access-Key: YOUR_ACCESS_KEY" \
-H "Content-Type: application/json" \
-d @- \
--output [email protected]The result is the same card at 2400 by 1260 pixels. The layout does not change. Every element is drawn with twice the detail.
#Where to go next
- HTML and Markdown input: everything about sending your own HTML.
- Caching: how stored copies and
cache_keywork. - Custom CSS, JavaScript and clicks: change a page before it is captured.
- Node.js examples: more complete samples, including retries.