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 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.
In your GitHub repository, go to Settings → Secrets and variables → Actions and add a new secret:
RENDERPIX_API_KEYrpx_... key from the RenderPix dashboardThe workflow will reference this secret as ${{ secrets.RENDERPIX_API_KEY }} without ever exposing the value in logs or build output.
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.
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}`); }
With images landing at public/og/[slug].png, reference them in your blog's generateMetadata function using the post slug:
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.
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.
Get your API key, add it as a GitHub secret, and every future post ships with a social card automatically.