Open Graph (OG) images are one of those small details that can make your post look professional and credible when you share it across social media. But if you use a generic image, or no image at all, your post can be easy to ignore.
In this guide, we’ll build a simple system using Astro and Cloudinary. The goal is to stop creating social images for every single post manually. Instead, we’ll upload one clean base template to Cloudinary and generate unique OG images dynamically using the post title and description.
The stack:
- Astro manages the content and page metadata.
- Cloudinary handles the image generation via URL transformations.
- One template powers every post on your site.
The result is a workflow that’s lightweight, easy to maintain, and much more scalable than exporting static images one by one.
Here’s the home page, which shows the full idea in action. Each post card displays its own generated OG image. Even though the text is different for every post, they all share a consistent design system.

The base image stays mostly blank on purpose. This is what makes the system flexible. We’re not “baking” the title or description into the image file. We’ll leave space for Cloudinary to add those later as text overlays.

Once the template is in Cloudinary, Astro passes post data into a transformation URL. Cloudinary then renders the final social image on the fly. This is the core of the whole build.

| Manual Process (The Old Way) | Dynamic Process (The New Way) |
|---|---|
| 1. Write a post | 1. Write a post |
| 2. Open Figma or Canva | 2. Done. Astro and Cloudinary handle the rest. |
| 3. Manually type the title | |
| 4. Export the image | |
| 5. Upload to your project | |
This gives you a better developer experience:
- Less manual work. No more opening design tools for every blog entry.
- Consistent branding. Your design stays the same across every post automatically.
- Faster updates. If you change a post title, the OG image updates instantly.
- Clean repository. No more bloated folders full of exported PNG files.
Instead of spending time on manual exports, you move to a system where your code handles the design logic.
Start with a fresh Astro application. By adding Tailwind CSS and shadcn/ui, you can build a polished interface quickly without spending hours on custom CSS.
The architecture is straightforward. Astro handles the routing and content, Tailwind provides the layout tools, and shadcn ensures the UI components like cards and buttons look professional from the start.
Run these commands in your terminal to set up the environment and add the necessary integrations:
# Create a new Astro project
npm create astro@latest astro-cloudinary-og-demo
# Navigate into the project
cd astro-cloudinary-og-demo
# Add Tailwind and React (required for shadcn)
npx astro add tailwind
npx astro add react
# Initialize shadcn/ui
npx shadcn@latest init -t astro
Code language: PHP (php)
For this demo, you’ll use a simple directory structure to keep the logic organized:
- src/data/blog/ stores our Markdown files for the posts.
- src/lib/ contains the Cloudinary helper functions.
- src/pages/ houses the homepage and the individual post routes.
At this stage, you should have a basic Astro site running. If you want to follow along with the exact boilerplate code, you can find it here: GitHub Repository
Once the project is running, the next step is to give Astro real content to work with. You’ll use Astro Content Collections to manage your blog posts. By storing posts as Markdown files, you can easily validate the data structure using a schema.
Each post contains the specific fields needed for the OG image, such as the title and description.
In src/content.config.ts, you’ll define the blog collection. Use Zod (integrated into Astro) to validate the shape of each post.
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/data/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
readTime: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };
Code language: JavaScript (javascript)
This configuration is the backbone of the project. The loader tells Astro where the Markdown files live, while the schema ensures every post has the required fields the application expects.
The individual posts live in src/data/blog/. Each file uses frontmatter to store its metadata.
--
title: "Build Dynamic OG Images With Astro and Cloudinary"
description: "Use Astro page data and Cloudinary overlays to generate branded social preview images."
pubDate: 2026-03-19
draft: false
readTime: "4 min read"
tags: ["astro", "cloudinary", "open-graph"]
---
Post content goes here...
Code language: JavaScript (javascript)
This is where the dynamic logic begins. The title and description are not only used for the text on the page. They also become the raw strings that Cloudinary will place on your social image later.
This part of the process is more important than it looks. The template acts as the visual foundation for every generated image. If the design is too busy, the text overlays will fight for space. If it’s too plain, the final image feels empty.
The sweet spot is a clean, branded background with intentional white space for the title and description. For this project, you’ll use one blank template image uploaded to Cloudinary. It stays empty on purpose because Cloudinary adds the post-specific data later through the URL.
To keep the system reusable, you’ll need to distinguish what’s static vs. what’s dynamic.
| Included in Template (Static) | Not Included (Dynamic) |
|---|---|
| Soft off-white background | Post title |
| Subtle blue glow or accent | Post description |
| Footer branding band | Post-specific icons |
| Small logo or brand name | Variable screenshots |
This separation is what makes the system scalable. Instead of “baking” information into the image, you provide a canvas.
Once the base image is ready, upload it to your Cloudinary account. Organizing it into a specific folder helps keep things tidy as your project grows.
- Folder Name:
astro-cloudinary-og-demo - Public ID:
astro-cloudinary-og-demo/og-template
That Public ID is crucial. It acts as the unique identifier you’ll use in our code to tell Cloudinary which base image to transform.
To keep the project portable and secure, store your Cloudinary credentials in your .env file. Using the PUBLIC_ prefix in Astro allows these values to be accessible on the client side if needed.
PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
PUBLIC_CLOUDINARY_OG_TEMPLATE=astro-cloudinary-og-demo/og-template
- Cloud Name directs the request to your specific Cloudinary account.
- OG Template specifies the exact base image to be used for the transformation.
This setup provides a stable visual frame. Astro supplies the changing content, and Cloudinary stitches them together into a final image. You only design the asset once, but you can generate an infinite number of social cards for greater efficiency.
Instead of storing dozens of static files in your repository, you store one template and let the URL do the work.
This is the logic that makes the entire system work. Instead of saving a finished image for each post, you’ll generate the image dynamically through a Cloudinary URL. That URL points to your base template and layers on text overlays for the post title and description.
The helper function takes two pieces of post data (the title and description) and combines them with your Cloudinary cloud name, template public ID, and a specific set of text overlay transformations.
In src/lib/generateOgImage.ts, the helper reads the Cloudinary values from the environment, encodes the text, and returns the final image URL.
type GenerateOgImageOptions = {
title: string,
description?: string,
};
export function generateOgImage({
title,
description = "",
}: GenerateOgImageOptions) {
const cloudName = import.meta.env.PUBLIC_CLOUDINARY_CLOUD_NAME;
const templateId = import.meta.env.PUBLIC_CLOUDINARY_OG_TEMPLATE;
const encodedTitle = encodeOverlayText(title);
const encodedDescription = encodeOverlayText(description);
const transformations = [
"f_auto",
"q_auto",
"w_1200,h_630,c_fill",
`l_text:Arial_54_bold_line_spacing_-10:${encodedTitle},co_rgb:0F172A,w_700,c_fit,g_north_west,x_72,y_86`,
`l_text:Arial_25:${encodedDescription},co_rgb:64748B,w_620,c_fit,g_north_west,x_74,y_245`,
].join("/");
return `https://res.cloudinary.com/${cloudName}/image/upload/${transformations}/${templateId}.png`;
}
Code language: JavaScript (javascript)
The transformation array is the engine of the build. It’s split into two parts: global image settings and text layers.
These parameters handle the final output of the file:
-
f_autoautomatically picks the best image format for the browser. -
q_autochooses the optimal balance between file size and visual quality. -
w_1200,h_630,c_fillresizes the image to the standard Open Graph dimensions.
This string creates the primary visual hook:
-
l_text:Arial_54_boldsets the font, size, and weight. -
line_spacing_-10tightens the title lines for a more modern look. -
co_rgb:0F172Asets the text color to a deep navy. -
w_700,c_fitdefines a maximum width of 700px and ensures the text wraps correctly. -
g_north_west,x_72,y_86positions the text from the top-left corner.
The description uses similar logic but with a different visual hierarchy:
- Smaller font size (
25). - Softer text color (
64748B). - Lower vertical position (
y_245).
Before placing text into a URL, you have to encode it. This ensures that spaces, commas, or slashes don’t break the Cloudinary transformation logic.
function encodeOverlayText(text: string) {
return encodeURIComponent(text)
.replace(/,/g, "%2C")
.replace(/\//g, "%2F");
}
Code language: JavaScript (javascript)
This small utility is vital. The standard encodeURIComponent doesn’t always handle Cloudinary-specific characters, like commas. This function ensures your titles and descriptions render correctly.
Once the Cloudinary helper is ready, the next step is simple. You’ll read the posts from Astro, generate one OG image per post, and show those previews on the homepage.
Astro gives us the post data, and Cloudinary turns that data into an image URL.
Start by loading the blog collection in src/pages/index.astro:
---
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog'))
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
---
Code language: JavaScript (javascript)
You’ll receive a clean list of published posts, sorted by date.
Inside the page loop, you’ll call the helper for each post:
{
posts.map((post) => {
const ogImage = generateOgImage({
title: post.data.title,
description: post.data.description,
});
return (
// render card
);
})
}
Code language: JavaScript (javascript)
This is the key handoff. Astro already knows the post title and description, so you’ll just pass those values into the Cloudinary helper.
Each card uses the generated image URL in a normal img tag:
<img
src={ogImage}
alt={`Open Graph preview for ${post.data.title}`}
class="aspect-[1200/630] w-full object-cover bg-muted"
loading="lazy"
/>
Code language: JavaScript (javascript)
That means every card shows the final Cloudinary image, not a placeholder.
Below the image, you’ll also render the post metadata and a few actions like opening the OG image, opening the post page, and sharing it.
For the full homepage file, check the GitHub:
Make sure to add the actual Open Graph and Twitter meta tags so each article is ready to share.
In src/pages/posts/[slug].astro, you’ll create a route for each Markdown post with getStaticPaths():
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
Code language: JavaScript (javascript)
This tells Astro to build one page per post.
After receiving the post data, call the same Cloudinary helper:
const ogImage = generateOgImage({
title: post.data.title,
description: post.data.description,
});
const pageUrl = `https://astro-cloudinary-og-demo.vercel.app/posts/${post.id}/`;
Code language: JavaScript (javascript)
Now you’ll have both:
- the generated OG image
- the live page URL
This is the part social platforms care about:
<meta property='og:title' content={post.data.title} />
<meta property='og:description' content={post.data.description} />
<meta property='og:type' content='article' />
<meta property='og:url' content={pageUrl} />
<meta property='og:image' content={ogImage} />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={post.data.title} />
<meta name='twitter:description' content={post.data.description} />
<meta name='twitter:image' content={ogImage} />
Code language: HTML, XML (xml)
These tags tell X, Facebook, LinkedIn, and other social platforms what to show when the page is shared.
On the homepage, we also changed the share buttons to use the post page URL, not the raw Cloudinary image URL.
const postUrl = `https://astro-cloudinary-og-demo.vercel.app/posts/${post.id}/`;
That is important because social platforms expect a page with metadata, not just a direct image link.
For the full implementation, check:
At this point, the full flow is in place. Astro owns the content, Cloudinary generates the social image, and each post page shows the correct metadata when shared across channels.
The last step is ensuring the setup behaves exactly as you expect.
The most effective way to verify your work is to open a post page and inspect the result. Go down this checklist:
- The text in the browser tab matches the text rendered on the social image.
- The image URL loads correctly without broken transformations.
- The page source has
og:imageandtwitter:imagetags. (These are what social platforms actually read.) - The link preview looks correct for X or LinkedIn when you check it with a tool like the Open Graph Visualizer.
By moving the design logic into the URL, you remove several friction points from your publishing workflow.
You’ll no longer need to export a new OG image for every single post, maintain a bloated folder of static social assets in your repository, and then open a design tool every time a post title changes. With just one high-quality template, Astro and Cloudinary handle the repetitive work. The OG image becomes a dynamic, generated part of your content system.
This project is a solid foundation, but there’s always room to iterate. Now that you have the core logic working, consider these improvements:
- Author profiles. Add the author’s name or avatar as a second overlay.
- Dynamic categories. Use different text colors or icons based on the post tags.
- Dark mode support. Create a second “Dark Template” and toggle the base image ID based on a theme preference.
- Featured images. Layer a small thumbnail of the post’s featured image into the corner of the OG card.
That’s the beauty of a dynamic system. Once you’ve set the logic, adding a new feature is just a matter of updating a URL string.
You can see the final result in action or dive into the underlying code: