Type something to search...
How to add Open Graph images in Astro?

How to add Open Graph images in Astro?

To add Open Graph images in Astro, render an og:image meta tag in the <head> of each page with the absolute URL of a 1200 by 630 pixel image, plus og:image:width, og:image:height, og:image:alt and a twitter:card of summary_large_image. The image can be a file you place in public/, a path from your content frontmatter, or one generated automatically. For automatic images, create an endpoint such as src/pages/og/[id].png.ts that uses getStaticPaths() to loop over your content and renders a PNG for each entry with Satori and resvg at build time, then point each page's og:image at that URL.

The Open Graph image is what appears when someone shares your link in Slack, LinkedIn, X, Discord, WhatsApp or iMessage. A clear, readable card noticeably improves how often shared links are clicked. In this article you'll learn the tags that control link previews, how to set a static image per page, how to generate images for every post at build time, how to style them, how to connect them to your SEO component, and how to test the result.

The Tags That Control Link Previews

Platforms read a small set of meta tags. The ones that matter for the image are:

TagValueWhy
og:imageAbsolute URL of the imageRequired; relative paths don't work
og:image:width / og:image:height1200 / 630Lets platforms lay out the card before downloading the image
og:image:altA short descriptionRead by screen readers on some platforms
og:image:typeimage/png or image/jpegOptional hint
twitter:cardsummary_large_imageShows a large image on X instead of a small thumbnail
twitter:imageAbsolute URLOptional; X falls back to og:image

A size of 1200 by 630 pixels, a 1.91:1 ratio, works well across platforms. Keep important text away from the edges, because some apps crop the image, and keep the file under a few hundred kilobytes so previews load quickly. PNG and JPEG are the safest formats; SVG is not supported for og:image by the major platforms.

Setting the site Option

Because og:image needs an absolute URL, set your production domain in the config:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  site: "https://example.com",
});

You can then build full URLs in any component with new URL(path, Astro.site).

Option 1: A Static Image per Page

The simplest approach is one hand-made image per page, stored in public/ so its URL is predictable:

public/
  og-default.png
  images/
    first-post.png

Reference it from your post's frontmatter, with a site-wide default for pages that don't set one:

---
title: "Moving my blog to Astro"
description: "Why I moved from a single-page app to Astro, and what changed for performance and SEO."
pubDate: 2026-10-01
image: "/images/first-post.png"
---

Then render the tags in your head component:

---
// src/components/OgImage.astro
interface Props {
  image?: string;
  alt: string;
}

const { image = "/og-default.png", alt } = Astro.props;
const imageURL = new URL(image, Astro.site);
---

<meta property="og:image" content={imageURL} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={imageURL} />

Images in public/ are copied as they are, without optimisation, so export them at the right size and compress them before adding them. This approach suits small sites and landing pages where each image is designed individually. For a blog with dozens of posts, generating them is far less work.

Option 2: Generate Images at Build Time

The generated approach uses two libraries:

  • Satori: converts a tree of elements with CSS-like styles into an SVG. It's the library Vercel's @vercel/og is built on, and it supports a subset of CSS based on flexbox.
  • resvg: renders that SVG to a PNG. @resvg/resvg-js is a fast native Node.js binding.

Install them, plus a font. Satori needs the raw font data in TTF, OTF or WOFF format; it doesn't read WOFF2:

npm install satori @resvg/resvg-js @fontsource/inter

The Fontsource package conveniently includes WOFF files for each weight. In my test, the versions installed were Satori 0.63.2 and @resvg/resvg-js 2.6.2.

The Rendering Function

Put the rendering logic in a helper so it can be reused by different routes:

// src/lib/og-image.ts
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import { readFile } from "node:fs/promises";

const regular = await readFile(
  "./node_modules/@fontsource/inter/files/inter-latin-400-normal.woff",
);
const bold = await readFile(
  "./node_modules/@fontsource/inter/files/inter-latin-700-normal.woff",
);

export async function renderOgImage(title: string, description: string) {
  const svg = await satori(
    {
      type: "div",
      props: {
        style: {
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          justifyContent: "flex-end",
          gap: "24px",
          padding: "72px",
          boxSizing: "border-box",
          background: "#0f172a",
          color: "#f8fafc",
          fontFamily: "Inter",
        },
        children: [
          {
            type: "div",
            props: {
              style: { fontSize: 28, color: "#38bdf8", marginBottom: "auto" },
              children: "example.com",
            },
          },
          {
            type: "div",
            props: {
              style: { fontSize: 64, fontWeight: 700, lineHeight: 1.1 },
              children: title,
            },
          },
          {
            type: "div",
            props: {
              style: { fontSize: 30, color: "#cbd5e1" },
              children: description,
            },
          },
        ],
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [
        { name: "Inter", data: regular, weight: 400, style: "normal" },
        { name: "Inter", data: bold, weight: 700, style: "normal" },
      ],
    },
  );

  return new Resvg(svg).render().asPng();
}

Satori examples are usually written in JSX, but Satori accepts plain objects with type and props, which is exactly what JSX compiles to. Using objects means you don't need a JSX setup or a UI framework integration in an Astro project.

The font files are read once when the module loads, not once per image. The path is relative to the project root, which is the working directory during astro build.

A Note on boxSizing

My first version of this layout cut the description off the bottom of the image. Inspecting the SVG showed why: the root element measured 1344 by 774 pixels, the 1200 by 630 canvas plus 72 pixels of padding on each side. With width: "100%", Satori added the padding outside the width. Adding boxSizing: "border-box" fixed it. If text disappears off the edge of your cards, check this first.

The Endpoint

Next, create a static endpoint that generates one PNG per post. The .png.ts file name means the built file is /og/<id>.png:

// src/pages/og/[id].png.ts
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { renderOgImage } from "../../lib/og-image";

export const getStaticPaths = (async () => {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}) satisfies GetStaticPaths;

export const GET: APIRoute<{ post: CollectionEntry<"blog"> }> = async ({ props }) => {
  const png = await renderOgImage(props.post.data.title, props.post.data.description);
  return new Response(new Uint8Array(png), {
    headers: { "Content-Type": "image/png" },
  });
};

This works exactly like a dynamic page route: getStaticPaths() returns one entry per post, and Astro calls GET for each one during the build. The dynamic routes and API endpoints guides cover both ideas in more detail.

Running the build in an Astro 7.4.2 test project generated both images:

├─ /og/first-post.png (+1.57s)
├─ /og/second-post.png (+207ms)
file dist/og/*.png
dist/og/first-post.png:  PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlaced
dist/og/second-post.png: PNG image data, 1200 x 630, 8-bit/color RGBA, non-interlaced

The first image took longer because it included loading the libraries; the second took about 200 milliseconds. The files were 35 KB and 16 KB. For a site with hundreds of posts, this adds noticeably to build time, so generate images only for content that needs them.

Connecting Images to Your Pages

Each post page now points its og:image at the generated file. If you already have a Seo component that takes an image prop, as in adding SEO meta tags in Astro, pass the generated path:

---
// src/pages/blog/[id].astro
import { getCollection, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";

export async function getStaticPaths() {
  const posts = await getCollection("blog", ({ data }) => !data.draft);
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<BaseLayout
  title={post.data.title}
  description={post.data.description}
  image={post.data.image ?? `/og/${post.id}.png`}
  type="article"
>
  <article>
    <h1>{post.data.title}</h1>
    <Content />
  </article>
</BaseLayout>

The ?? lets a post override the generated image with a hand-made one in its frontmatter. Because both routes loop over the same filtered collection, every post page has a matching image.

Designing a Good Card

A link preview is shown small, often on a phone, next to other content. Some practical guidelines:

  • Few words, large type: the title alone is often enough. A 60 to 72 pixel title stays readable when the card is shrunk.
  • Handle long titles: Satori wraps text automatically, but a very long title can still overflow. Reduce the font size for long strings, for example fontSize: title.length > 60 ? 52 : 64.
  • Strong contrast: light text on a dark background, or the reverse, survives compression and small screens.
  • Consistent branding: a logo or domain name in the same position on every card helps people recognise your links.
  • Safe margins: keep text at least 60 pixels from the edges.

Satori supports flexbox layout, borders, gradients, border-radius, images and many common properties, but not everything in CSS. Every element with more than one child needs display: "flex". Check Satori's documentation for the supported properties when a style doesn't apply.

Adding Images and Emoji

Satori can include images, such as a logo, as img elements with a src. In a build-time setup, the simplest option is to read the file and pass it as a data URL. Emoji and characters outside your font's subset need extra font files or Satori's loadAdditionalAsset option; without them, those characters won't render.

Generating Images on Demand

Build-time generation suits content that changes when you deploy. If titles come from a database or user input, you can generate images on request instead by adding export const prerender = false to the endpoint and reading the data from params. Keep in mind:

  • An adapter is required, and native modules like @resvg/resvg-js may not run on every platform. Edge runtimes usually need a WebAssembly build such as @resvg/resvg-wasm.
  • Reading fonts from node_modules at runtime may fail if your deployment doesn't include that folder. Import the font as an asset or fetch it instead.
  • Cache the response, because rendering takes time. Astro 7's route caching works well here, as described in caching on-demand pages.

For most blogs and documentation sites, prerendered images are simpler, faster and free to serve.

Testing Your Images

  1. Open the image URL directly: after a build, open dist/og/<id>.png, or visit /og/<id>.png on the dev server.
  2. Check the head: view the page source and confirm og:image is an absolute URL on your production domain.
  3. Use platform debuggers: LinkedIn's Post Inspector and Facebook's Sharing Debugger fetch the page and show the card. They also refresh cached previews after you change an image.
  4. Share it privately: paste the URL into a chat with yourself in Slack or another app to see the real card.

Platforms cache previews aggressively. If an old image keeps appearing, use the debugger's re-scrape option, or change the image URL, for example by adding a version query string.


FAQ: Open Graph Images in Astro

1200 by 630 pixels is the widely recommended size. It has a 1.91:1 ratio that works for large preview cards on most platforms. Keep text away from the edges in case of cropping.

The most common causes are a relative URL instead of an absolute one, a missing site option so the domain is wrong, an image that returns an error on the deployed site, or a platform still showing a cached preview. Use the platform's debugger to see what it fetched.

Yes. Satori accepts plain objects with type and props, so you can describe the layout without JSX, render it to SVG with Satori and convert it to PNG with resvg in a normal Astro endpoint.

Satori reads the font data itself to lay out and draw text, and it supports TTF, OTF and WOFF but not WOFF2. Fontsource packages include WOFF files you can read with Node's file system API.

No. Major platforms don't support SVG for og:image. Render your design to PNG or JPEG, which is what resvg does in the build-time approach.

A little. In my test, each image took around 200 milliseconds after the first. For large sites, only generate images for content that needs them, or let posts provide their own image in frontmatter.


Conclusion

Open Graph images in Astro come down to one tag with an absolute URL, backed by a few supporting tags for size, alt text and the X card type. For a handful of pages, place designed images in public/ and reference them from frontmatter. For a blog or docs site, a static endpoint that loops over your collection with getStaticPaths() and renders each card with Satori and resvg gives every post a consistent image with no manual work.

Set boxSizing: "border-box" when you use padding, give every multi-child element display: "flex", keep titles large and readable, and let individual posts override the generated card when they need to. Then check the result in platform debuggers, and your links will look as good when they're shared as your pages do when they're visited.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper