Generate dynamic images (OG images, social media cards, etc.) using React components in your Astro projects. Powered by Takumi.
- 🎨 Design images with React components and JSX
- 🖼️ Generate PNG, JPEG, or JPG images
- 🐛 Debug mode to preview templates in browser
- 😀 Built-in emoji support with Twemoji
- 🌍 Automatic multilingual support (Thai, Japanese, Korean, Arabic)
- 🔤 Custom fonts with automatic fallbacks
- ⚡ Fast Rust-based rendering
pnpm add @bearstudio/astro-assets-generationUpdate your astro.config.mjs:
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import { astroAssetsGeneration } from "@bearstudio/astro-assets-generation";
export default defineConfig({
integrations: [react(), astroAssetsGeneration()],
});The integration configures the Takumi Vite/SSR settings for you. On Vercel, it
also includes Takumi's native Linux binding in the serverless function output,
so consumers do not need custom includeFiles config.
Create a configuration file (e.g., src/lib/assets.ts):
import { configure } from "@bearstudio/astro-assets-generation";
import { diskLoader } from "@bearstudio/astro-assets-generation/disk-loader";
configure({
debugBackground: "#0a0a0a",
siteUrl: import.meta.env.SITE ?? "http://localhost:4321",
isDev: import.meta.env.DEV,
customFonts: [
// Optional
{
name: "Geist",
url: "/fonts/Geist.ttf",
weight: 400,
style: "normal",
},
],
// Required for `output: 'static'` (prerender has no server). Omit for SSR
// adapters — the library will fetch from `siteUrl` instead.
loadAsset: diskLoader(),
});Create a React component prefixed with _:
// src/pages/blog/[slug]/assets/_og-image.tsx
import { FontWrapper } from "@bearstudio/astro-assets-generation";
import type { AssetImageConfig } from "@bearstudio/astro-assets-generation";
export const config: AssetImageConfig = {
width: 1200,
height: 630,
debugScale: 0.5, // Optional: for debug view
};
export default function OgImage({ params }: { params: { slug: string } }) {
return (
<FontWrapper fontFamily="Geist">
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
padding: 64,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}
>
<h1 style={{ color: "white", fontSize: 72, fontWeight: "bold" }}>
My Blog Post
</h1>
<p style={{ color: "white", fontSize: 24 }}>Post: {params.slug}</p>
</div>
</FontWrapper>
);
}// 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"; // Import your config
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 }))
);
};
export const GET: APIRoute = apiImageEndpoint(modules);- PNG:
/blog/my-post/assets/og-image.png - JPEG:
/blog/my-post/assets/og-image.jpg - Debug:
/blog/my-post/assets/og-image.debug
By default, the examples above use getStaticPaths to pre-generate images at build time — no server adapter required.
If you need to generate images on-demand (e.g., for a very large number of routes or frequently changing content), you can use prerender = false instead. This requires an Astro server adapter.
pnpm astro add vercel
# or
pnpm astro add nodeimport { defineConfig } from "astro/config";
import react from "@astrojs/react";
import vercel from "@astrojs/vercel";
import { astroAssetsGeneration } from "@bearstudio/astro-assets-generation";
export default defineConfig({
integrations: [react(), astroAssetsGeneration()],
adapter: vercel(),
});Replace getStaticPaths with prerender = false:
// src/pages/blog/[slug]/assets/[__image].[__type].ts
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 })
);When you ship to a serverless adapter (Vercel, Netlify, Cloudflare…), remove
loadAsset: diskLoader() from your configure() call. The library will fetch
images and fonts from siteUrl at runtime, and the disk-loader import is no
longer needed. Keeping it in serverless builds causes file tracers like
@vercel/nft to bundle your entire dist/ into the function output.
No Vercel-specific app config is required beyond adding the Vercel adapter and
astroAssetsGeneration(). The integration handles Takumi's native Node binding
for the Vercel Linux runtime.
The library provides default fallback font definitions for Thai, Japanese, Korean, and Arabic. These fonts are loaded from a CDN at render time, so the consumer app does not need to install or ship non-Latin font packages.
If your deployment cannot rely on CDN access, register your own fonts with
customFonts.
Add fonts in your configuration:
const customFonts: FontConfig[] = [
{
name: "Geist", // Must match font's internal name
url: "/fonts/Geist.ttf", // Path or URL
weight: 400,
style: "normal",
},
];Automatically creates a font stack with fallbacks:
import { FontWrapper } from "@bearstudio/astro-assets-generation";
<FontWrapper fontFamily="Geist">
<div style={{ padding: 64 }}>
<p>English, 日本語, 한국어, العربية, ไทย - all supported!</p>
</div>
</FontWrapper>;Emojis are detected and rendered automatically inside any text node. Just write them inline:
return <h1>Hello 👋 World 🌍</h1>;The library uses Twemoji SVGs by default, fetched from a CDN at render time.
Use inline styles via the style prop:
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
backgroundColor: "#3b82f6",
}}
>
<h1 style={{ color: "white", fontSize: 96, fontWeight: "bold" }}>Hello</h1>
</div>import { getAstroImageBase64 } from "@bearstudio/astro-assets-generation";
const avatarBase64 = await getAstroImageBase64(author.data.avatar);
<img
src={avatarBase64}
style={{ width: 128, height: 128, borderRadius: 9999 }}
/>;<img src="https://example.com/image.jpg" style={{ width: 256, height: 256 }} />Use jsxToBase64 to render any JSX component as a PNG and get a base64 data URI. This is useful for embedding generated images inside other templates.
import { jsxToBase64 } from "@bearstudio/astro-assets-generation";
const base64 = await jsxToBase64(
<div tw="flex items-center justify-center w-full h-full bg-blue-500">
<h1 tw="text-white text-4xl">Hello</h1>
</div>,
{ width: 600, height: 300 }
);
<img src={base64} tw="w-64 h-32" />;configure({
debugBackground: "#0a0a0a",
siteUrl: "https://example.com",
isDev: import.meta.env.DEV,
customFonts: [
/* ... */
],
// Optional. Resolves `getAstroImageBase64` images and `/`-prefixed font
// URLs before falling back to fetch. Required for `output: 'static'`.
loadAsset: diskLoader(),
});Imported from @bearstudio/astro-assets-generation/disk-loader. Returns an
AssetLoader that reads a request URL from disk, looking under
process.cwd()/dist/ then process.cwd()/public/ by default.
import { diskLoader } from "@bearstudio/astro-assets-generation/disk-loader";
loadAsset: diskLoader();
loadAsset: diskLoader({ directories: ["dist", "public", "custom"] });Lives in a sub-export so its process.cwd() references stay out of the main
entry — without that split, @vercel/nft would trace dist/** into
serverless function bundles.
Creates an Astro API route handler:
export const GET = apiImageEndpoint(
import.meta.glob("./_*.tsx", { eager: true })
);Generates static paths for all combinations of templates and image types. Automatically derives template names from the glob modules.
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 }))
// optional 3rd arg: ["png", "jpg"] by default
);
};Converts Astro image to base64 data URI.
Renders a JSX element as a PNG and returns a base64 data URI. Useful for embedding a generated image inside another template.
const base64 = await jsxToBase64(<MyComponent />, { width: 600, height: 300 });Wraps content with automatic font fallback support.
interface AssetImageConfig {
width: number;
height: number;
debugScale?: number; // Default: 0.5
}interface FontConfig {
name: string;
url: string;
weight: number;
style: "normal" | "italic";
}type AssetLoader = (url: string) => Promise<Buffer | null | undefined>;Return null or undefined to fall back to fetch.
Images not generating?
- Verify template starts with
_(e.g.,_og-image.tsx) - Check API route uses
[__image].[__type].ts(double underscores) - Import config file in API route
Fonts not loading?
- Verify font name matches internal font name
- Check
siteUrlis correct in production - Ensure custom font URLs are accessible from production URL
- Ensure the deployment environment can reach the CDN for built-in fallback fonts
Vercel cannot load Takumi native bindings?
- Ensure
astroAssetsGeneration()is present inastro.config - Ensure optional dependencies are installed during deployment
Styling issues?
- Use the
styleprop with inline CSS objects - Test in debug mode (
.debugextension) - Stick to well-supported CSS features
// src/pages/blog/[slug]/assets/_og-image.tsx
import { FontWrapper } from "@bearstudio/astro-assets-generation";
import { getEntry } from "astro:content";
export const config = { width: 1200, height: 630 };
export default async function BlogOgImage({
params,
}: {
params: { slug: string };
}) {
const post = await getEntry("blog", params.slug);
return (
<FontWrapper fontFamily="Geist" style={{ width: "100%", height: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
padding: 64,
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: "auto",
}}
>
<h1 style={{ color: "white", fontSize: 72, fontWeight: "bold" }}>
{post.data.title}
</h1>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<p style={{ color: "white", fontSize: 24 }}>{post.data.author}</p>
<p style={{ color: "white", fontSize: 24 }}>
{new Date(post.data.date).toLocaleDateString()}
</p>
</div>
</div>
</FontWrapper>
);
}MIT