Auto-Generate Social Cards for Every Blog Post with GitHub Actions

OG images are one of those tasks that developers finish once, for the first few posts, and then quietly stop doing. The process is manual: design the image, export it, name it correctly, commit it alongside the post. Multiply that by every future post and every future author, and the result is a blog where half the posts share with a blank preview or the same generic fallback image.

The fix is to generate OG images in CI, not by hand. This tutorial wires a GitHub Actions workflow to your blog repository so that every merged post automatically gets a unique social card — committed back to the repo and ready to use before the deployment finishes.

The approach

The workflow runs on push to the main branch. It finds all markdown files that were added or modified, reads the frontmatter from each one, calls the RenderPix API to render a 1200×630 PNG for each post, and commits the images to a public/og/ directory. Your Next.js or Astro site then references these static images in its og:image meta tags.

This approach generates images once at deploy time rather than on every page request, which means zero API calls at runtime and no latency added to page loads.

Step 1: Store your API key as a secret

In your GitHub repository, go to Settings → Secrets and variables → Actions and add a new secret:

The workflow will reference this secret as ${{ secrets.RENDERPIX_API_KEY }} without ever exposing the value in logs or build output.

Step 2: Create the workflow file

yaml — .github/workflows/og-images.yml
name: Generate OG images

on:
  push:
    branches: [main]
    paths:
      - 'content/blog/**/*.md'
      - 'content/blog/**/*.mdx'

jobs:
  generate:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install gray-matter
        run: npm install gray-matter

      - name: Generate OG images for changed posts
        env:
          RENDERPIX_API_KEY: ${{ secrets.RENDERPIX_API_KEY }}
        run: node .github/scripts/generate-og.mjs

      - name: Commit generated images
        run: |
          git config user.name  "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add public/og/
          git diff --staged --quiet || git commit -m "chore: generate OG images [skip ci]"
          git push

The paths filter ensures the workflow only runs when blog content files actually change — not on every push to main. The [skip ci] tag in the commit message prevents an infinite loop where the bot's commit triggers the workflow again.

Step 3: Write the generation script

js — .github/scripts/generate-og.mjs
import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, basename } from 'node:path';
import matter from 'gray-matter';

// Find files changed in this push vs the previous commit
const changed = execSync('git diff --name-only HEAD~1 HEAD -- "content/blog/**"')
  .toString()
  .trim()
  .split('\n')
  .filter(f => f.endsWith('.md') || f.endsWith('.mdx'));

if (!changed.length) {
  console.log('No blog files changed.');
  process.exit(0);
}

mkdirSync('public/og', { recursive: true });

for (const file of changed) {
  const { data } = matter.read(file);
  const title    = data.title    ?? 'Untitled';
  const desc     = data.excerpt  ?? data.description ?? '';

  const ogUrl = new URL('https://renderpix.dev/og-image');
  ogUrl.searchParams.set('title',  title);
  ogUrl.searchParams.set('desc',   desc);
  ogUrl.searchParams.set('domain', 'myblog.com');
  ogUrl.searchParams.set('api_key', process.env.RENDERPIX_API_KEY);

  const res = await fetch(ogUrl.toString());
  if (!res.ok) {
    console.error(`Failed to render OG image for ${file}: ${res.status}`);
    continue;
  }

  const slug    = basename(file, extname(file));
  const outPath = join('public/og', `${slug}.png`);
  writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));
  console.log(`Generated: ${outPath}`);
}

Step 4: Reference the image in your posts

With images landing at public/og/[slug].png, reference them in your blog's generateMetadata function using the post slug:

tsx — app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return {
    title: post.title,
    openGraph: {
      images: [{
        url: `/og/${params.slug}.png`,
        width: 1200,
        height: 630,
      }],
    },
  };
}

If a post is brand new and its OG image has not been committed yet (for example, you are previewing a PR), the og:image URL will 404. Add a fallback: check whether the static file exists at build time, and fall back to a dynamic /og?title=... URL if not.

Handling custom templates

The script above uses the /og-image query-parameter endpoint which renders a default RenderPix template. If you have a branded template you want to use instead, swap the fetch call to POST to /v1/render with your HTML string — the rest of the script stays identical. The generated files are still saved to public/og/ and committed by the bot.

Automate your OG images today.

Get your API key, add it as a GitHub secret, and every future post ships with a social card automatically.

← All articles API Reference →