How to Serve Open Graph Tags in Server-Rendered HTML
Serve route-specific Open Graph tags in the initial HTML response so link previews receive the right title, description, URL, and image.
To serve Open Graph tags in server-rendered HTML, put route-specific <meta property="og:..."> elements in the document <head> that your server returns for the shared URL. The four basic Open Graph properties are og:title, og:type, og:image, and og:url. Add og:description for a useful preview summary. Make each value match the requested route.
What the server must return
Open Graph metadata is document metadata, not visible page copy. The Open Graph Protocol defines these properties as additional <meta> tags in the HTML document head. Its four basic properties are title, type, image, and URL. See the Open Graph Protocol.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Guide to Example</title>
<meta property="og:title" content="Guide to Example">
<meta property="og:type" content="article">
<meta property="og:url" content="https://example.com/guides/example">
<meta property="og:image" content="https://example.com/images/example-preview.jpg">
<meta property="og:description" content="A concise description of this guide.">
</head>
<body>
<h1>Guide to Example</h1>
</body>
</html>
Use the canonical URL for the object in og:url. Use an absolute image URL that can be retrieved publicly, and check the target platform’s current image and crawler guidance. Platform-specific image requirements and crawler behavior are not universal.
Build metadata from the requested route
For a route such as /articles/[slug], resolve the article first, then produce its title, description, canonical URL, and social image in the response. A generic head shared by every route can produce incorrect previews. Dynamic values must be escaped for HTML by the template or framework.
Keep the data lookup and metadata rendering on the server, or generate route-specific HTML at build time for static content. Client-side code that changes the DOM after hydration is not proof that the initial HTTP response contains the tags. Inspect the response for the exact URL you intend to share.
Framework-neutral server rendering
The framework-neutral implementation is: map the request path to content, validate the metadata fields, serialize escaped values into the head, then render the page. The following Node.js example uses only built-in modules and a small in-memory record map. Save it as server.mjs and run node server.mjs. Visit http://localhost:3000/articles/hello.
import http from 'node:http';
const articles = {
hello: {
title: 'Hello from the server',
description: 'A route-specific server-rendered page.',
canonicalUrl: 'http://localhost:3000/articles/hello',
imageUrl: 'https://example.com/images/hello.jpg',
body: 'This page receives its metadata on the server.'
}
};
function escapeHtml(value) {
return String(value).replace(/[&<>\"']/g, (char) => ({
'&': '&',
'<': '<',
'>': '>',
'\"': '"',
"'": '''
})[char]);
}
function absoluteHttpUrl(value) {
const url = new URL(value);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Metadata URLs must use HTTP or HTTPS');
}
return url.href;
}
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://localhost:3000').pathname;
const match = pathname.match(/^\/articles\/([a-z0-9-]+)$/);
const article = match ? articles[match[1]] : undefined;
if (!article) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Not found');
return;
}
const title = escapeHtml(article.title);
const description = escapeHtml(article.description);
const canonicalUrl = escapeHtml(absoluteHttpUrl(article.canonicalUrl));
const imageUrl = escapeHtml(absoluteHttpUrl(article.imageUrl));
const body = escapeHtml(article.body);
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${title}</title>
<meta name="description" content="${description}">
<meta property="og:title" content="${title}">
<meta property="og:type" content="article">
<meta property="og:url" content="${canonicalUrl}">
<meta property="og:image" content="${imageUrl}">
<meta property="og:description" content="${description}">
</head>
<body><main><h1>${title}</h1><p>${body}</p></main></body>
</html>`;
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, max-age=60'
});
res.end(html);
});
server.listen(3000, () => console.log('Listening on http://localhost:3000'));
Replace the sample record with your database or content store. In production, the canonical URL should use your public origin, not localhost. Return a 404 when the route has no content instead of emitting another article’s metadata.
Next.js App Router
In Next.js App Router, use a static metadata export when values are known for the route. Use generateMetadata when values depend on fetched content or route parameters. Both are Server Component APIs. See the Next.js metadata API and its metadata and OG image guide. Do not export both mechanisms from the same route segment.
This TypeScript example assumes a current App Router project with an app/articles/[slug]/page.tsx route and an existing server-side getArticle function. Adjust the parameter typing to your installed Next.js version.
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getArticle } from '@/lib/articles';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) return {};
return {
title: article.title,
description: article.summary,
alternates: { canonical: article.canonicalUrl },
openGraph: {
title: article.title,
description: article.summary,
type: 'article',
url: article.canonicalUrl,
images: [{ url: article.socialImage }]
}
};
}
export default async function ArticlePage({ params }: Props) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) notFound();
return <article><h1>{article.title}</h1><p>{article.body}</p></article>;
}
The sample uses promise-based route params as in current Next.js documentation; older installed versions may type and provide params differently. Keep the lookup consistent between page rendering and metadata generation, and handle missing content deliberately. A static route can instead export a Metadata object directly.
Metadata inheritance and nested objects
Next.js combines metadata across route segments, but nested Open Graph objects have replacement behavior: defining a route-level openGraph can replace the parent’s object. Repeat or deliberately compose shared description and image fields in the child if they should remain. Inspect the final tags for each route rather than assuming parent fields survived.
Streaming and crawler behavior
Next.js can stream UI for dynamic routes before generateMetadata finishes. Its documentation says metadata is interpreted by JavaScript-capable bots after the full DOM is available; for HTML-limited bots such as facebookexternalhit, metadata blocks rendering so it is in the head. Next.js detects these bots using the user-agent header and provides htmlLimitedBots to override its list. Overriding the list can increase response time. Do not assume all social platforms use the same crawler or interpret every field identically; check the current guidance and preview tool for each platform you target.
React and other rendering setups
React documents that its built-in <meta> component places the resulting element in the document head, regardless of where that component appears in the React tree. That placement behavior alone does not prove a deployment’s first HTTP response contains route-specific tags. Confirm the response HTML, or use a framework/server-rendering path that emits the metadata before the response is delivered. See React’s meta component reference.
In any server-rendered stack, the same rules apply: use the request’s route to load its record, escape values for the output context, and emit the metadata in the response head. Static site generation is also suitable when each route gets its own generated HTML.
Choose and serve the Open Graph image
og:image should point to the image intended for that route. In Next.js, an opengraph-image file can be static or generated by code for a route segment; the convention can emit image type, dimensions, and alt metadata. The Next.js documentation lists JPEG, PNG, and GIF as supported static formats and describes limits of 8 MB for opengraph-image and 5 MB for twitter-image. Those are Next.js convention/build limits, not universal platform limits. See the Next.js Open Graph image convention.
Make the image URL absolute and publicly retrievable. Confirm that it resolves to the intended asset and check its dimensions, format, and accessibility against the destination platform’s current requirements. An image that works in your logged-in browser may still be unavailable to an external crawler.
Verify the actual response
- Request the exact public route, not only the home page.
- Inspect the raw HTML with View Source or an HTTP client. Confirm the expected
og:tags are present in the returned head. - Check that each title, description, canonical URL, and image belongs to that route.
- Check that dynamic values containing characters such as
&, quotes, and angle brackets are safely escaped. - Open the image URL independently and check it against the platform’s current crawler and image rules.
- For Next.js, inspect the final merged metadata, especially when parent and child segments both define
openGraph. - After changing metadata, deployment, or cache behavior, request the route again and use the target platform’s current preview/debugging tool if available.
For a quick response check, save the page HTML and search for the properties:
curl -sS -L https://example.com/articles/hello > page.html
rg -o 'property="og:[^"]+"[^>]*' page.html
This checks what the HTTP client received. It does not establish that a particular social platform can fetch the URL or accepts the chosen image; use that platform’s own current guidance and preview tooling for those questions.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Tags appear in DevTools but not View Source or the HTTP response | Client-side code adds them after the initial HTML. | Generate metadata on the server or at build time, then inspect the exact route response. |
| Every article preview has the same title or image | The head uses global values instead of route-resolved content, or a cached response is shared too broadly. | Resolve metadata from the route record and make cache variation match the URL and content. |
| Open Graph description or image disappears on a nested Next.js route | A child openGraph object replaced the parent’s nested object. |
Repeat or compose the shared fields in the child and inspect the resolved head. |
| Metadata is missing for an unknown slug | The content lookup returned no record, but the route still rendered as if it existed. | Return the framework’s not-found response; do not reuse another route’s metadata. |
| Image preview is absent | The image URL may be relative, inaccessible to the crawler, or outside the destination’s accepted format or constraints. | Use an absolute public URL, check the response and image, then consult the platform’s current image guidance. |
| Metadata looks stale after a change | A build or cached response may still contain old output, or a platform may retain a prior preview. | Request the deployed route again, inspect its HTML and cache behavior, and use the platform’s current refresh/debug tool where available. |
| Special characters break a tag or alter markup | Dynamic values were interpolated without HTML escaping. | Use the framework’s safe metadata serializer or context-appropriate escaping; do not concatenate untrusted values into raw HTML. |
Performance, reliability, and cost
For static content, build-time generation avoids a per-request content lookup. For dynamic content, keep metadata lookup efficient and reuse the same content source as the page so title and body do not drift. Next.js streaming can let the UI begin before dynamic metadata resolves, while HTML-limited bots can wait for metadata; account for that documented behavior when evaluating response time. Next.js cautions that overriding its HTML-limited bot list may increase response time.
Metadata does not make an image publicly reachable or guarantee a preview. Treat the route HTML and image as separate fetchable resources, and re-check both after releases or cache changes. No universal crawler cache duration or cross-platform image limit is implied here; consult each target platform’s current documentation.
The implementation uses your existing server or static build, so the relevant cost is your framework, data lookup, image generation, and hosting work. No separate screenshot service is required to serve Open Graph tags. If you also need to inspect how a page renders visually, a screenshot capture is a separate task.
Or skip the browser setup
If you need a rendered screenshot of the page while checking a route, ScreenshotNeo is a website screenshot API and MCP server for developers. It can capture PNG, JPEG, WebP, or PDF with one GET request. This does not replace serving correct Open Graph metadata in your HTML.
cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/articles/hello -o shot.webp
Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com/articles/hello"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://example.com/articles/hello'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));
See the ScreenshotNeo API documentation for request options. Its captures remove cookie banners, newsletter popups, and chat widgets before the shot; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; and its MCP server provides screenshot tools for AI agents. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Does Open Graph require a description tag?
The four basic protocol properties are title, type, image, and URL. A description is an additional useful field for the preview summary.
Can I use one image for every route?
You can, but route-specific images usually describe individual objects more accurately. Ensure the URL you emit is public and appropriate for the shared page.
Should I put these tags in the body?
Emit them in the document head as metadata, and verify their presence in the HTML response for the route.
Will correct tags guarantee a social preview?
No. The tags provide metadata, but crawler access, platform-specific rules, and preview caching also affect what a platform displays.


