- What the finished thing looks like
- Route one: nuxt-og-image
- Route two: render your real HTML through an API
- Fully static sites
- Picking between them
A link with no Open Graph image is the one that gets scrolled past. If your Nuxt site has more than a handful of pages, a per-page share image needs to be generated, not exported from a design tool one at a time.
There are two good routes in Nuxt, and the right one depends on how much design fidelity you need. This article sets up both: the nuxt-og-image module, which renders Vue components through Satori, and a cached Nitro route that renders your real HTML through an API.
What the finished thing looks like
Whichever route you take, the destination is the same. Every sharable page serves a 1200 by 630 PNG at an absolute URL, referenced from its head:
useSeoMeta({
ogImage: `https://example.com/og/${slug}.png`,
ogImageWidth: 1200,
ogImageHeight: 630,
twitterCard: 'summary_large_image',
})
The scrapers from LinkedIn, Slack, X and friends fetch that URL once and cache it, so the image needs to exist by the time the page is shared, and it needs to survive being fetched by a bot that runs no JavaScript.
Route one: nuxt-og-image
nuxt-og-image is part of the Nuxt SEO suite and is the fastest path to working images.
npx nuxi module add og-image
Define the design as a Vue component in components/OgImage/:
<!-- components/OgImage/BlogPost.vue -->
<script setup lang="ts">
defineProps<{ title: string; author: string }>()
</script>
<template>
<div class="w-full h-full flex flex-col justify-center bg-slate-900 text-white p-16">
<h1 class="text-6xl font-bold leading-tight">{{ title }}</h1>
<p class="text-3xl text-slate-400 mt-6">{{ author }}</p>
</div>
</template>
Then point each page at it:
defineOgImageComponent('BlogPost', {
title: post.title,
author: post.author,
})
At build or on request, the module renders the component to a PNG. No API keys, no extra infrastructure, and prerendering works out of the box.
The constraint is the renderer. In production the module typically renders through Satori, which is not a browser. It supports a subset of CSS: flexbox but no grid, a limited property set, fonts you must supply explicitly, and its own ideas about emoji. Simple card designs work well. Anything using grid, masks, custom font features or clever pseudo-elements quietly falls apart. There is a useful list of what Satori will and will not render on the HTML to Image blog, worth reading before you commit to a design.
If your design fits inside those limits, use the module and stop reading here. The rest of this article is for when it does not.
Route two: render your real HTML through an API
When the share image has to match your actual brand, same fonts, same gradients, same layout system, the reliable move is to render real HTML in a real browser. Running your own Chrome for one image type is a lot of ops, so hand the rendering to an API and cache the result.
First, a template. This is a plain function returning an HTML string, so everything CSS can do is on the table, grid included:
// server/utils/ogTemplate.ts
export function ogTemplate(post: { title: string; author: string }) {
return `<!doctype html>
<html><head><style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;800&display=swap');
body { margin: 0; width: 1200px; height: 630px; display: grid;
grid-template-rows: 1fr auto; padding: 72px; box-sizing: border-box;
background: linear-gradient(120deg, #0f172a, #1e3a8a);
color: #fff; font-family: Inter, sans-serif; }
h1 { font-size: 72px; font-weight: 800; letter-spacing: -0.02em;
align-self: center; margin: 0; }
p { font-size: 30px; color: #93c5fd; margin: 0; }
</style></head>
<body><h1>${post.title}</h1><p>${post.author}</p></body></html>`
}
Then a Nitro route that renders each slug once and remembers the answer for a month:
// server/routes/og/[slug].png.ts
const ogImageUrl = defineCachedFunction(
async (slug: string) => {
const post = await getPost(slug)
const res = await $fetch<{ url: string }>('https://app.html2img.com/api/html', {
method: 'POST',
headers: { 'X-API-Key': useRuntimeConfig().html2imgKey },
body: { html: ogTemplate(post), width: 1200, height: 630 },
})
return res.url
},
{ name: 'og-image', getKey: (slug: string) => slug, maxAge: 60 * 60 * 24 * 30 },
)
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')!.replace(/\.png$/, '')
return sendRedirect(event, await ogImageUrl(slug), 302)
})
Each slug costs one render until the cache expires or you bust it. The route itself just issues a redirect to the hosted PNG, and the major scrapers follow redirects. If you would rather serve the bytes from your own origin, fetch the URL inside the handler and return the buffer with an image/png content type; the caching logic stays the same.
We covered the same pattern for Laravel in How to generate dynamic Open Graph images in Laravel, if part of your estate lives there.
Fully static sites
If you ship with nuxi generate there is no server route at request time, so generate the images at build instead. Loop your content, call the render API from a build script, and write the returned URLs into each page's meta. The guide to generating OG images in GitHub Actions walks through exactly that pipeline, including only re-rendering the pages that changed.
Picking between them
If a flexbox-only card design covers your brand, nuxt-og-image is fewer moving parts and free. If the image has to be your actual design, a template plus a cached Nitro route gives you the whole of CSS for one API call per page. There is a longer comparison of the module and API-based rendering if you are weighing the two. Either way: set the tags with useSeoMeta, keep the URL absolute, and check the result in a scraper debugger before you ship.