Discord Open Graph Image Generator
Build dynamic Discord preview images with correct Open Graph metadata, cache handling, image formats, and reliable debugging steps.
Direct answer: A Discord Open Graph image generator creates an image from your page or post data, serves it from a stable public HTTPS URL, and places that URL in the page’s og:image metadata. Discord fetches the shared page, reads its title, description, and image, then serves the retrieved media through Discord infrastructure. Your generator must therefore return a supported raster format, respond quickly enough for Discord to fetch it, and account for Discord caching when an image changes.
How Discord preview images work
When a user shares a URL, Discord visits that URL and extracts metadata. The page should emit at least these tags:
<meta property="og:title" content="Your article title">
<meta property="og:description" content="A concise description of the page.">
<meta property="og:type" content="article">
<meta property="og:url" content="https://example.com/articles/hello">
<meta property="og:image" content="https://example.com/og/hello.png">
The image URL must be publicly reachable. A private hostname, localhost URL, login wall, robots restriction, expired signed URL, or an endpoint that requires browser JavaScript will prevent retrieval. Return PNG, JPEG, WebP, or GIF with the matching Content-Type.
Choose a generator design
| Approach | Best for | Trade-offs |
|---|---|---|
| Static files | A small set of pages whose artwork rarely changes | Simple and cheap, but every variation needs a file |
| Runtime generation | Blogs, catalogs, dashboards, and user-created pages | Flexible, but uses CPU and needs caching |
| Hosted API | Teams that want generation without browser or image-rendering infrastructure | Recurring service cost and an external dependency |
For runtime generation, make the image URL deterministic: derive it from a page identifier and a content version. Store or cache the result so repeated Discord fetches do not render the same image repeatedly.
Build a runtime generator with Node.js
The example below uses Express and Sharp. It creates a 1200×630 PNG, sets a cache policy, and escapes user data before putting it into SVG. Install dependencies:
npm init -y
npm install express sharp
const express = require('express');
const sharp = require('sharp');
const app = express();
const port = process.env.PORT || 3000;
function escapeXml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function makeSvg(title, description) {
const safeTitle = escapeXml(title).slice(0, 180);
const safeDescription = escapeXml(description).slice(0, 260);
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#17153b"/>
<stop offset="1" stop-color="#5865f2"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<circle cx="1030" cy="100" r="180" fill="#ffffff" opacity=".12"/>
<circle cx="110" cy="560" r="240" fill="#ffffff" opacity=".08"/>
<text x="90" y="250" fill="#ffffff" font-family="Arial, sans-serif" font-size="64" font-weight="700">${safeTitle}</text>
<text x="90" y="340" fill="#e7e9ff" font-family="Arial, sans-serif" font-size="30">${safeDescription}</text>
</svg>`;
}
app.get('/og/:id.png', async (req, res) => {
// Replace this lookup with your database or CMS.
const records = {
hello: { title: 'A practical guide', description: 'Build useful previews for shared links.' }
};
const record = records[req.params.id];
if (!record) return res.status(404).send('Not found');
try {
const png = await sharp(Buffer.from(makeSvg(record.title, record.description)))
.png()
.toBuffer();
res.set({
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable'
});
res.send(png);
} catch (error) {
console.error(error);
res.status(500).send('Image generation failed');
}
});
app.listen(port, () => console.log(`Listening on ${port}`));
Deploy this behind HTTPS at a public hostname. Then reference the endpoint from the page head:
<meta property="og:image" content="https://example.com/og/hello.png?v=3">
The version query parameter is useful when you intentionally need a new URL after changing the artwork. Keep the underlying image response cacheable.
Generate the image in Python
This Flask and Pillow example writes a PNG response directly:
python -m pip install flask pillow
from flask import Flask, Response, abort
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
app = Flask(__name__)
DATA = {
"hello": {
"title": "A practical guide",
"description": "Build useful previews for shared links."
}
}
@app.get('/og/<slug>.png')
def og_image(slug):
item = DATA.get(slug)
if not item:
abort(404)
image = Image.new('RGB', (1200, 630), '#5865f2')
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((55, 55, 1145, 575), radius=32, fill='#17153b')
draw.text((100, 220), item['title'][:80], fill='white')
draw.text((100, 320), item['description'][:140], fill='#e7e9ff')
output = BytesIO()
image.save(output, format='PNG', optimize=True)
return Response(
output.getvalue(),
mimetype='image/png',
headers={'Cache-Control': 'public, max-age=31536000, immutable'}
)
if __name__ == '__main__':
app.run(port=3000)
Use a real font file and text-wrapping routine in production. Always constrain input lengths and handle missing glyphs.
Test the generator with cURL
curl -I https://example.com/og/hello.png
curl -L https://example.com/og/hello.png -o preview.png
Confirm that the response is 200, the content type is an image type, and the body is non-empty. Open the downloaded file to catch clipping, missing fonts, and incorrect colors.
Metadata and content limits
- Set
og:title,og:description,og:type,og:url, andog:imageon the final HTML response. - Use the canonical page URL in
og:url, not the image URL. - Keep generated text short enough to fit the design. Discord documents a 256-character embed title limit, a 2,048-character description limit, a maximum of 25 fields, and a 6,000-character total embed limit.
- Do not put confidential data, access tokens, or personal information into an image URL or rendered artwork.
If you create embeds through a Discord bot, the Embed Object also supports a title, description, URL, image, thumbnail, author, provider, and related fields. Image references can be HTTP(S) URLs or uploaded attachments referenced with the attachment scheme.
Cache invalidation and versioning
Discord may retain a retrieved preview. Treat image URLs as immutable assets: publish a new path or query-string version when the source data changes.
<meta property="og:image" content="https://example.com/og/post-123-v4.png">
For high-volume sites, persist generated files in object storage or a CDN and use a long cache lifetime. For frequently edited drafts, use a short cache lifetime until publication, then switch to immutable URLs.
Reliability and security checklist
- Serve the HTML and image over HTTPS with a valid certificate.
- Make the image endpoint work without cookies, authorization headers, or client-side JavaScript.
- Return a supported raster format and the correct MIME type.
- Set timeouts around database and image operations.
- Use a bounded queue or concurrency limit so a burst of shares cannot exhaust memory.
- Cache by content hash or version.
- Escape text before inserting it into SVG or HTML.
- Reject untrusted remote image URLs or proxy them through an allowlist.
- Log status code, generation time, cache hit or miss, and image size.
Performance and cost considerations
Rendering once and serving the resulting file is cheaper and more predictable than rendering on every request. Pre-generate images during publishing when possible. Runtime generation is appropriate when content is user-generated or changes often, but protect it with caching and rate limits. Keep the canvas dimensions and font count fixed to reduce rendering work.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No image preview | Image URL is private, invalid, or blocked | Open it from an unauthenticated network and verify a 200 response. |
| Broken-image icon | Wrong MIME type or truncated body | Return image/png, image/jpeg, or image/webp and verify the downloaded bytes. |
| Old artwork appears | Cached URL | Publish a new versioned image URL and update og:image. |
| Title or description is missing | Tags are absent, duplicated, or generated only in the browser | Emit metadata in the server-rendered HTML head. |
| Text is clipped | Long input or missing wrapping | Wrap lines, limit characters, and reserve space for the longest supported title. |
| Generation times out | Slow database, remote asset, or overloaded renderer | Pre-fetch assets, add timeouts, cache results, and move rendering to a queue. |
| Only some pages fail | Missing record, invalid slug, or unsupported characters | Validate identifiers, provide a fallback template, and escape Unicode safely. |
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. It can capture a public page after removing cookie banners, newsletter popups, and chat widgets, and it returns PNG, JPEG, WebP, or PDF. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing status. Its MCP server gives Claude, Cursor, and other MCP clients take_screenshot, get_page_info, and capture_pdf tools.
See the ScreenshotNeo API documentation for all options. A direct capture looks like this:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/article -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/article"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/article' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo supports full-page capture, CSS selectors, dark mode, device presets, custom viewports, retina scale, custom CSS and JavaScript, waits, request blocking, headers, cookies, user agents, timezone, geolocation, resizing, caching, signed links, asynchronous jobs, bulk capture, and a usage API. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Can Discord use a data URI as the page’s og:image?
Use a public HTTP(S) image URL for page metadata. Discord’s API documentation also describes data-URI image input in API contexts and attachment URLs for uploaded embed images.
Should every page have a unique image?
No. Reuse a default image for pages without custom artwork, but use deterministic per-page URLs when the image contains page-specific data.
Can the generator run only when someone shares a link?
Yes. A runtime endpoint can generate on the first request and cache the result. Pre-generation during publishing is usually simpler for high-traffic pages.
What should happen when source data is unavailable?
Return a valid fallback image and keep the page’s metadata internally consistent. A broken image response is harder to diagnose than a branded fallback.


