Tutorial: Generate OG images on demand
In the static tutorial every image is rendered during
astro build. That is ideal for a few hundred pages. With tens of thousands of
pages, or content that changes between deploys, you want the image rendered
when someone first asks for it.
In this tutorial you will take the blog from the static tutorial and move its image route to on-demand rendering with the Node adapter. The blog pages stay static. Only the image route runs on the server.
By the end you will have:
- A Node server that serves the site.
- An image route that renders any post’s image on the first request, including posts that did not exist at build time.
- Correct 404 responses for unknown posts.
- Cache headers so the image is rendered once, not on every hit.
Expect about 15 minutes.
Prerequisites
Section titled “Prerequisites”- The finished project from the static tutorial. If you skipped it, complete steps 1 to 6 of that tutorial first. You need the blog collection, the config file, the template and the API route.
1. Add a server adapter
Section titled “1. Add a server adapter”Astro needs an adapter to run code at request time.
pnpm astro add nodeAnswer yes to every prompt. Your config now has an adapter entry:
import { defineConfig } from "astro/config";import react from "@astrojs/react";import { astroAssetsGeneration } from "@bearstudio/astro-assets-generation";import node from "@astrojs/node";
export default defineConfig({ site: "https://example.com", integrations: [react(), astroAssetsGeneration()], adapter: node({ mode: "standalone" }),});Leave output at its default. Pages are still prerendered unless a file opts
out, which is exactly what the image route will do.
2. Opt the image route out of prerendering
Section titled “2. Opt the image route out of prerendering”Open the API route. Delete the getStaticPaths export and its two imports
(getStaticPathsForAssets and getCollection), then add
export const prerender = false. The file becomes:
import { apiImageEndpoint } from "@bearstudio/astro-assets-generation";import type { APIRoute } from "astro";import "../../../../lib/assets";
export const prerender = false;
export const GET: APIRoute = apiImageEndpoint( import.meta.glob("./_*.tsx", { eager: true }),);There is no list of paths anymore. Whatever slug, __image and __type
appear in the URL are passed straight to the handler.
3. Remove the disk loader
Section titled “3. Remove the disk loader”The disk loader exists for builds without a server. Now that a server runs, the
library can fetch fonts and images from siteUrl over HTTP, and the disk
loader gets in the way: bundlers trace its dist/ reads and ship your whole
build output inside the server function.
import { configure } from "@bearstudio/astro-assets-generation";import { diskLoader } from "@bearstudio/astro-assets-generation/disk-loader";
configure({ siteUrl: import.meta.env.SITE ?? "http://localhost:4321", isDev: import.meta.env.DEV, // Static builds have no server to fetch from, so fonts and images are // read from `dist/` and `public/` on disk instead. loadAsset: diskLoader(),});4. Return a 404 for unknown posts
Section titled “4. Return a 404 for unknown posts”With static paths, a URL for a missing post simply did not exist. Now every URL
reaches the template, and the template’s throw new Error(...) would surface as
a 500. Throw the library’s not-found error instead and the handler answers 404.
import { getEntry } from "astro:content";import type { AssetImageConfig } from "@bearstudio/astro-assets-generation";import { NotFoundAssetError } from "@bearstudio/astro-assets-generation";
export const config: AssetImageConfig = { width: 1200, height: 630,};
export default async function OgImage({ params,}: { params: { slug: string };}) { const post = await getEntry("blog", params.slug); if (!post) throw new Error(`Unknown post: ${params.slug}`); if (!post) throw new NotFoundAssetError();
// ... unchanged}5. Build and run the server
Section titled “5. Build and run the server”pnpm buildpnpm previewThe build output no longer lists any og-image.png files: nothing is rendered
yet. The Node adapter prints the address it listens on, usually
http://localhost:4321.
Now request an image:
curl -o hello.png -w "%{http_code} %{content_type}\n" \ http://localhost:4321/blog/hello-world/assets/og-image.pngYou get 200 image/png and the server log shows the template being rendered.
Open hello.png to check it.
Try the other formats and a missing post:
curl -o /dev/null -w "%{http_code}\n" http://localhost:4321/blog/hello-world/assets/og-image.jpgcurl -o /dev/null -w "%{http_code}\n" http://localhost:4321/blog/does-not-exist/assets/og-image.pngcurl -o /dev/null -w "%{http_code}\n" http://localhost:4321/blog/hello-world/assets/og-image.gifExpected responses:
| URL | Status |
|---|---|
og-image.jpg |
200 |
| unknown slug | 404 |
unsupported .gif type |
404 |
6. Add cache headers
Section titled “6. Add cache headers”Every request now renders the image. Rendering takes tens of milliseconds and some CPU, so tell browsers and CDNs to keep the result. Wrap the handler:
import { apiImageEndpoint } from "@bearstudio/astro-assets-generation";import type { APIRoute } from "astro";import "../../../../lib/assets";
export const prerender = false;
const render = apiImageEndpoint( import.meta.glob("./_*.tsx", { eager: true }),);
export const GET: APIRoute = async (context) => { const response = await render(context); if (response.ok) { response.headers.set( "Cache-Control", "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800", ); } return response;};Rebuild, restart the preview server and check the header:
curl -sI http://localhost:4321/blog/hello-world/assets/og-image.png | grep -i cache-controlBehind a CDN this means the image is rendered roughly once a day per post, no matter how many people share the link.
What you built
Section titled “What you built”The blog pages are still static files. The image route is a small server function that renders any post’s image on request, returns 404 for unknown posts and lets caches hold the result. If you publish a new post without rebuilding, its image renders the first time a crawler asks for it.
Where to go next
Section titled “Where to go next”- Deploy to Vercel for the serverless version of this setup.
- Add custom fonts, keeping in mind the server
fetches them from
siteUrl. - Reference: API route for the full list of response codes and the params your template receives.