Astro
Put the render call in an Astro endpoint and your API key never reaches the browser: the browser, and every social crawler, only ever sees a URL on your own domain. Imejis returns the finished image directly in the response, so an endpoint is a single upstream request, with nothing to poll and no webhook to receive.
Set two environment variables first:
IMEJIS_API_KEY=your_api_key
IMEJIS_DESIGN_ID=your_design_idThe fields you override are component.property pairs: headline.text, title.color, image.image. Only what you send changes, and a design's overridable fields are listed in its Share / API panel.
Every file below comes from a real Astro app that is booted in Docker and called end to end before this page ships. The title of each block is where the file goes.
Configure the adapter and the environment variablesConfigure the adapter and the environment variables
An endpoint that renders an image runs on demand, so it needs an adapter. astro:env then types the variables and decides which ones may reach the browser: a secret never does, which is what keeps the render key on the server.
import node from "@astrojs/node"
import { defineConfig, envField } from "astro/config"
export default defineConfig({
// An endpoint that renders an image has to run on demand, which needs an
// adapter. Swap in the one for your host.
output: "server",
adapter: node({ mode: "standalone" }),
env: {
schema: {
// `secret` is read from the environment when the app runs and is never
// exposed to the browser — so the render call has to happen in an
// endpoint, which is the point.
IMEJIS_API_KEY: envField.string({ context: "server", access: "secret" }),
// Not secrets: the design id is in the URL of every render anyway.
IMEJIS_DESIGN_ID: envField.string({
context: "server",
access: "public",
}),
IMEJIS_RENDER_URL: envField.string({
context: "server",
access: "public",
default: "https://render.imejis.io",
}),
// The variation id of a multi-size design's second canvas — what the
// /og-size endpoint maps its "story" size to.
IMEJIS_VARIATION_ID: envField.string({
context: "server",
access: "public",
default: "story",
}),
},
},
})An endpoint that returns the generated imageAn endpoint that returns the generated image
A route in your app that renders on demand and streams the image straight back, so your render key never reaches the browser. This is the shape to use behind an <img src> on a page you control. ?format= picks png, jpeg, webp or pdf, and the route answers with the content type that matches it. A PDF labelled image/png is a file the browser, the email client and Slack all refuse to open.
import {
IMEJIS_API_KEY,
IMEJIS_DESIGN_ID,
IMEJIS_RENDER_URL,
} from "astro:env/server"
import type { APIRoute } from "astro"
// Rendering an image is on-demand work, not something to prerender.
export const prerender = false
// Render on demand and stream the image straight back. The key stays on the
// server, so it never reaches the browser — point an <img src>, or an
// og:image, at /og.
//
// `?format=` decides what comes back — png · jpeg · webp · pdf — and with no
// format the render host's default (jpeg) applies. Either way the response
// carries whatever type the render API set, passed straight through: a PDF
// labelled `image/jpeg` is a file the browser, the email client and Slack all
// refuse to open.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
export const GET: APIRoute = async ({ url }) => {
const title = url.searchParams.get("title") ?? "Hello from Astro"
const params = new URLSearchParams()
const requested = url.searchParams.get("format") ?? ""
if (FORMATS.includes(requested)) params.set("format", requested)
const upstream = await fetch(
`${IMEJIS_RENDER_URL}/v1/${IMEJIS_DESIGN_ID}?${params}`,
{
method: "POST",
headers: {
"dma-api-key": IMEJIS_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"headline.text": title,
"subtitle.text": url.searchParams.get("subtitle") ?? "",
}),
}
)
if (!upstream.ok) {
return new Response(`render failed: ${upstream.status}`, { status: 502 })
}
// Pass the body straight through — no buffering of the whole image.
return new Response(upstream.body, {
headers: {
"Content-Type": upstream.headers.get("content-type") ?? "image/jpeg",
// Renders are deterministic, so cache aggressively at the edge/CDN —
// and every request to the render API is one metered render.
"Cache-Control": "public, immutable, no-transform, max-age=31536000",
},
})
}One endpoint every size and jpeg qualityOne endpoint, every size, and JPEG quality
A multi-size design carries several canvases: an OG image, a square post, a vertical story. ?variation=<id> picks which one the route renders, and omitting it gives the base size, so one endpoint serves every size your pages ask for. ?quality= trades file size for fidelity on JPEG only; png, webp and pdf ignore it.
import {
IMEJIS_API_KEY,
IMEJIS_DESIGN_ID,
IMEJIS_RENDER_URL,
IMEJIS_VARIATION_ID,
} from "astro:env/server"
import type { APIRoute } from "astro"
// Rendering an image is on-demand work, not something to prerender.
export const prerender = false
// One design, several canvases — an OG image, a square post, a vertical
// story. `?variation=<id>` picks which one you get; with none you get the base
// size. Map YOUR OWN size names to the ids (the design's Share / API panel
// lists them) so a page asks for "story", not for an id.
//
// `?quality=` trades file size for fidelity on JPEG only — png, webp and pdf
// ignore it.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
const SIZES: Record<string, string | null> = {
og: null,
story: IMEJIS_VARIATION_ID,
}
export const GET: APIRoute = async ({ url }) => {
const size = url.searchParams.get("size") ?? "og"
if (!(size in SIZES)) {
return new Response(`unknown size: ${size}`, { status: 404 })
}
const params = new URLSearchParams()
const variation = SIZES[size]
if (variation) params.set("variation", variation)
const requested = url.searchParams.get("format") ?? ""
if (FORMATS.includes(requested)) params.set("format", requested)
const quality = Number(url.searchParams.get("quality"))
if (quality >= 1 && quality <= 100) params.set("quality", String(quality))
const upstream = await fetch(
`${IMEJIS_RENDER_URL}/v1/${IMEJIS_DESIGN_ID}?${params}`,
{
method: "POST",
headers: {
"dma-api-key": IMEJIS_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"headline.text": url.searchParams.get("title") ?? "Every size",
}),
}
)
if (!upstream.ok) {
return new Response(`render failed: ${upstream.status}`, { status: 502 })
}
return new Response(upstream.body, {
headers: {
"Content-Type": upstream.headers.get("content-type") ?? "image/jpeg",
"Cache-Control": "public, immutable, no-transform, max-age=31536000",
},
})
}An endpoint that returns a hosted or signed image urlAn endpoint that returns a hosted or signed image URL
Ask for ?delivery=hosted (a public URL) or ?delivery=signed (a private, expiring one) and hand the URL to the caller instead of the bytes, which is what you want for an email, a webhook payload, or a share card.
import {
IMEJIS_API_KEY,
IMEJIS_DESIGN_ID,
IMEJIS_RENDER_URL,
} from "astro:env/server"
import type { APIRoute } from "astro"
// Rendering an image is on-demand work, not something to prerender.
export const prerender = false
// Ask for a stored URL instead of bytes. `hosted` is public and permanent;
// `signed` is private and expires (`expiresIn` is minutes).
export const GET: APIRoute = async ({ url }) => {
const delivery = url.searchParams.get("private") ? "signed" : "hosted"
const params = new URLSearchParams({ delivery })
// The stored URL carries the extension of the format it was rendered in, so
// pass the format through here too — a recipient that sniffs by extension
// (an email client, a Slack unfurl) needs `.pdf` to say pdf.
const requested = url.searchParams.get("format") ?? ""
if (["png", "jpeg", "webp", "pdf"].includes(requested)) {
params.set("format", requested)
}
if (delivery === "signed") params.set("expiresIn", "30")
const upstream = await fetch(
`${IMEJIS_RENDER_URL}/v1/${IMEJIS_DESIGN_ID}?${params}`,
{
method: "POST",
headers: {
"dma-api-key": IMEJIS_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"headline.text": url.searchParams.get("title") ?? "Shared card",
}),
}
)
// { url, format, file: { size }, expiresAt (signed only) }
const stored = await upstream.json()
return Response.json({
url: stored.url,
format: stored.format,
expiresAt: stored.expiresAt ?? null,
})
}Bulk image generation from a routeBulk image generation from a route
One POST to /bulk renders many images, a row per record or one per size, and returns a URL for each, in the order you sent them.
import {
IMEJIS_API_KEY,
IMEJIS_DESIGN_ID,
IMEJIS_RENDER_URL,
} from "astro:env/server"
import type { APIRoute } from "astro"
// Rendering an image is on-demand work, not something to prerender.
export const prerender = false
// One POST renders many images and returns a URL for each, in the order sent —
// a row per record, or one per size.
export const POST: APIRoute = async ({ request }) => {
const { rows = [] } = await request.json()
const upstream = await fetch(`${IMEJIS_RENDER_URL}/bulk`, {
method: "POST",
headers: {
"dma-api-key": IMEJIS_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
designId: IMEJIS_DESIGN_ID,
delivery: "hosted",
format: "png",
data: rows.map((row: { name: string; role?: string }) => ({
"headline.text": row.name,
"subtitle.text": row.role ?? "",
})),
}),
})
const bulk = await upstream.json()
return Response.json({
summary: bulk.summary,
urls: bulk.results.map((r: { url?: string }) => r.url ?? null),
})
}Using the image url directly in a templateUsing the image URL directly in a template
For a public design you can skip the proxy entirely and point an <img> at the render URL with the field values in the query string. Keep the proxy when the key must stay private. That is the right default.
import {
IMEJIS_API_KEY,
IMEJIS_DESIGN_ID,
IMEJIS_RENDER_URL,
} from "astro:env/server"
export const prerender = false
const title = Astro.url.searchParams.get("title") ?? "Straight from a URL"
// og:image points at YOUR OWN endpoint (the proxy above), so crawlers and
// social platforms fetch your domain and the key stays server-side. Astro.site
// (or Astro.url.origin) gives the absolute URL a crawler needs.
const ogImage = `${Astro.url.origin}/og?title=${encodeURIComponent(title)}`
// For a PUBLIC design you can skip the proxy and point an <img> straight at
// the render URL. A .astro file never ships to the browser, but the key is in
// the markup it renders — so only do this with a key you are happy to expose.
// Keep the proxy whenever it must stay private: that is the right default.
const direct = new URL(`${IMEJIS_RENDER_URL}/v1/${IMEJIS_DESIGN_ID}`)
direct.searchParams.set("dma-api-key", IMEJIS_API_KEY)
direct.searchParams.set("headline.text", title)
---
<html lang="en">
<head>
<title>{title}</title>
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<img src={direct.toString()} width="1200" height="630" alt={`Card for ${title}`} />
</body>
</html>Dynamic OG images. The src/pages/card.astro block above is the whole link-preview pattern: the page's og:image points at your own /og endpoint, and Astro.url.origin makes it the absolute URL a crawler needs. X, LinkedIn, Slack and iMessage then fetch your domain and get a freshly rendered 1200×630 image for that page's real title.
The response is the raw image (`image/jpeg` by default). Choose the format with `?format=png|jpeg|webp|pdf` (default jpeg) and set JPEG quality with `?quality=1-100`. Store the result and get a URL back with `?delivery=hosted` (or `?delivery=signed&expiresIn=60`), and render a specific size of a multi-size design with `?variation=<id>`.
| Parameter | Values | Default | What it does |
|---|---|---|---|
?format= | png · jpeg · webp · pdf | jpeg | Output image format. |
?quality= | 1–100 | none | Compression quality for lossy output; ignored for other formats. (JPEG only) |
?delivery= | stream · hosted · signed | stream | stream = raw image bytes (default); hosted = store + return a public URL; signed = store + return a time-limited URL. |
?expiresIn= | minutes | 60 | Signed-URL lifetime in minutes (default 60, max 10080 = 7 days). (delivery=signed only) |
?variation= | <size id> | master | Which multi-dimension SIZE to render. Omit or use "master" for the base size; an unknown id falls back to the master. (multi-size designs only) |
- PNG (
?format=png): Lossless, supports transparency. Largest file. - JPEG (
?format=jpeg): Smaller, lossy, no transparency. Quality adjustable. - WebP (
?format=webp): Smallest, supports transparency. Modern browsers. - PDF (
?format=pdf): Vector page, for print and documents.
See the delivery modes and generate images with the API guides.
SvelteKit and Next.js have the same files for their routers, and the JavaScript page has the bare calls. Each endpoint needs prerender = false and an adapter, because a render happens per request: a static build has nowhere to run it. One render per request, 100 free a month, and pricing for the tiers.