Tutorial: Generate OG images for a static blog
In this tutorial you will create a small Astro blog and give every post its own Open Graph image. The images are designed as a React component, rendered with Takumi, and written to disk at build time. No server runs in production: the output is plain files you can host anywhere.
By the end you will have:
- A blog content collection with two posts.
- An image template that reads the post title and author.
- One PNG per post under
dist/blog/<slug>/assets/og-image.png. - The
<meta property="og:image">tag pointing at that PNG.
The tutorial takes about 20 minutes. It assumes you know the basics of Astro (pages, content collections) and React (JSX, props).
Prerequisites
Section titled “Prerequisites”- Node.js 18 or newer.
- pnpm (the commands below use it; npm or yarn work the same way).
- A terminal and a code editor.
1. Create the project
Section titled “1. Create the project”-
Scaffold an empty Astro project and move into it:
Terminal window pnpm create astro@latest og-blog -- --template minimal --no-install --no-gitcd og-blogpnpm install -
Add the React integration. The library renders templates written in JSX, so Astro needs to know how to compile
.tsxfiles:Terminal window pnpm astro add reactAnswer yes to every prompt. This installs
@astrojs/react,reactandreact-dom, and addsreact()to your Astro config. -
Install the library:
Terminal window pnpm add @bearstudio/astro-assets-generation
2. Register the integration
Section titled “2. Register the integration”Open astro.config.mjs and add astroAssetsGeneration() next to react().
Also set site: the library needs to know the public URL of your site to
resolve fonts and images.
import { defineConfig } from "astro/config";import react from "@astrojs/react";import { astroAssetsGeneration } from "@bearstudio/astro-assets-generation";
export default defineConfig({ site: "https://example.com", integrations: [react(), astroAssetsGeneration()],});The integration adjusts Vite so Takumi’s WebAssembly renderer can run inside Astro’s prerender step. You never need to touch that configuration yourself.
3. Add a blog collection
Section titled “3. Add a blog collection”You need something to put on the images. Create a blog collection with two
posts.
-
Define the collection:
src/content.config.ts import { defineCollection, z } from "astro:content";import { glob } from "astro/loaders";const blog = defineCollection({loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),schema: z.object({title: z.string(),author: z.string(),date: z.coerce.date(),}),});export const collections = { blog }; -
Write two posts:
src/content/blog/hello-world.md ---title: Hello, worldauthor: Ada Lovelacedate: 2026-01-10---The first post.src/content/blog/second-post.md ---title: Rendering images with Takumiauthor: Grace Hopperdate: 2026-02-03---The second post.
4. Configure the library
Section titled “4. Configure the library”The library is configured once, from a module you import wherever you generate images. Create it now.
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(),});Two lines matter most here:
isDevtells the library whether it runs under the dev server or a build. Always passimport.meta.env.DEV.loadAsset: diskLoader()is what makes static output work. Duringastro buildthere is no HTTP server, so anything the template needs is read from disk.
5. Write the image template
Section titled “5. Write the image template”A template is a React component in a file whose name starts with _. The
underscore keeps Astro from treating it as a page. Its default export receives
the route params, and it exports a config object with the image size.
Create the template next to where the API route will live:
import { getEntry } from "astro:content";import type { AssetImageConfig } 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}`);
return ( <div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", width: "100%", height: "100%", padding: 64, color: "white", background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", }} > <h1 style={{ fontSize: 72, fontWeight: "bold", lineHeight: 1.1 }}> {post.data.title} </h1> <p style={{ fontSize: 28 }}> {post.data.author} · {post.data.date.toLocaleDateString("en-GB")} </p> </div> );}Notice that the component is async. Templates can await anything: content
entries, fetches, file reads.
6. Create the API route
Section titled “6. Create the API route”The route turns templates into URLs. Its file name is fixed:
[__image].[__type].ts, with two underscores before each param name.
import { apiImageEndpoint, getStaticPathsForAssets,} from "@bearstudio/astro-assets-generation";import type { APIRoute } from "astro";import { getCollection } from "astro:content";import "../../../../lib/assets";
// Every `_*.tsx` file in this folder is a template.const modules = import.meta.glob("./_*.tsx", { eager: true });
export const getStaticPaths = async () => { const posts = await getCollection("blog"); return getStaticPathsForAssets( modules, posts.map((post) => ({ slug: post.id })), ["png"], );};
export const GET: APIRoute = apiImageEndpoint(modules);Three things happen here:
- The side-effect import of
lib/assetsrunsconfigure()before any image is rendered. getStaticPathsForAssetsbuilds one path per post, per template, per image type. With one template and["png"], that is one PNG per post.apiImageEndpointis the handler. It picks the template by name, renders it and returns the image.
Your src/pages folder should now look like this:
Directorysrc
Directorypages
Directoryblog
Directory[slug]
Directoryassets
- _og-image.tsx
- [__image].[__type].ts
- index.astro
7. Look at the result
Section titled “7. Look at the result”Start the dev server:
pnpm devOpen http://localhost:4321/blog/hello-world/assets/og-image.png. You should see a purple gradient with the post title and author.
Change the gradient colours in the template and reload: the image updates.
8. Reference the image from the page
Section titled “8. Reference the image from the page”An OG image is only useful if pages point to it. Create a page for each post and add the meta tag.
---import { getCollection, render } from "astro:content";
export async function getStaticPaths() { const posts = await getCollection("blog"); return posts.map((post) => ({ params: { slug: post.id }, props: { post }, }));}
const { post } = Astro.props;const { Content } = await render(post);const ogImage = new URL(`/blog/${post.id}/assets/og-image.png`, Astro.site);---
<html lang="en"> <head> <meta charset="utf-8" /> <title>{post.data.title}</title> <meta property="og:title" content={post.data.title} /> <meta property="og:image" content={ogImage} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta name="twitter:card" content="summary_large_image" /> </head> <body> <h1>{post.data.title}</h1> <Content /> </body></html>Astro.site comes from the site value in your config, so the tag holds an
absolute URL. Social networks require absolute URLs.
9. Build
Section titled “9. Build”Stop the dev server and build:
pnpm buildThe build log lists the generated files. Confirm they exist:
find dist/blog -name "*.png"Get-ChildItem -Recurse dist\blog -Filter *.pngYou should see:
dist/blog/hello-world/assets/og-image.pngdist/blog/second-post/assets/og-image.pngRun pnpm preview and open a post page. View the source: the og:image tag
points at a URL that now resolves to a real file.
What you built
Section titled “What you built”You have a static site where each blog post ships with a build-time generated Open Graph image. Adding a post adds an image. Changing the template changes every image on the next build.
Where to go next
Section titled “Where to go next”- Preview a template in the browser to iterate on the design without rendering a PNG each time.
- Add custom fonts to replace the default sans-serif.
- Embed images in a template to add an author avatar or a logo.
- Generate images on demand if your site grows to thousands of pages.