Skip to content

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).

  • 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. Scaffold an empty Astro project and move into it:

    Terminal window
    pnpm create astro@latest og-blog -- --template minimal --no-install --no-git
    cd og-blog
    pnpm install
  2. Add the React integration. The library renders templates written in JSX, so Astro needs to know how to compile .tsx files:

    Terminal window
    pnpm astro add react

    Answer yes to every prompt. This installs @astrojs/react, react and react-dom, and adds react() to your Astro config.

  3. Install the library:

    Terminal window
    pnpm add @bearstudio/astro-assets-generation

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.

astro.config.mjs
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.

You need something to put on the images. Create a blog collection with two posts.

  1. 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 };
  2. Write two posts:

    src/content/blog/hello-world.md
    ---
    title: Hello, world
    author: Ada Lovelace
    date: 2026-01-10
    ---
    The first post.
    src/content/blog/second-post.md
    ---
    title: Rendering images with Takumi
    author: Grace Hopper
    date: 2026-02-03
    ---
    The second post.

The library is configured once, from a module you import wherever you generate images. Create it now.

src/lib/assets.ts
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:

  • isDev tells the library whether it runs under the dev server or a build. Always pass import.meta.env.DEV.
  • loadAsset: diskLoader() is what makes static output work. During astro build there is no HTTP server, so anything the template needs is read from disk.

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:

src/pages/blog/[slug]/assets/_og-image.tsx
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.

The route turns templates into URLs. Its file name is fixed: [__image].[__type].ts, with two underscores before each param name.

src/pages/blog/[slug]/assets/[__image].[__type].ts
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/assets runs configure() before any image is rendered.
  • getStaticPathsForAssets builds one path per post, per template, per image type. With one template and ["png"], that is one PNG per post.
  • apiImageEndpoint is 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

Start the dev server:

Terminal window
pnpm dev

Open 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.

An OG image is only useful if pages point to it. Create a page for each post and add the meta tag.

src/pages/blog/[slug].astro
---
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.

Stop the dev server and build:

Terminal window
pnpm build

The build log lists the generated files. Confirm they exist:

Terminal window
find dist/blog -name "*.png"

You should see:

dist/blog/hello-world/assets/og-image.png
dist/blog/second-post/assets/og-image.png

Run 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.

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.