diff --git a/app/[...slug]/page.tsx b/app/[...slug]/page.tsx
index 65d9ca18..7eb3f76a 100644
--- a/app/[...slug]/page.tsx
+++ b/app/[...slug]/page.tsx
@@ -29,6 +29,7 @@ import * as customIcons from '@/components/ui/icon';
import { TagFilterSystem } from '@/components/ui/tag-filter-system';
import { getGitLastModified } from '@/lib/last-modified';
import { getAllFilterablePages, source } from '@/lib/source';
+import { DOCS_URL } from '@/lib/structured-data';
import type { HeadingProps } from '@/types';
export default async function Page(props: {
@@ -173,6 +174,7 @@ export default async function Page(props: {
path={canonicalPath}
datePublished={page.data.publishedAt}
dateModified={lastModified?.toISOString().slice(0, 10)}
+ image={`${DOCS_URL}/og${canonicalPath}`}
/>
)}
{page.data.interactive ? (
diff --git a/components/author-profile.tsx b/components/author-profile.tsx
index efd41857..431adb2d 100644
--- a/components/author-profile.tsx
+++ b/components/author-profile.tsx
@@ -1,5 +1,6 @@
import { Github, Globe } from 'lucide-react';
import Link from 'next/link';
+import { getAuthorPersonId } from '@/lib/structured-data';
const SITE_URL = 'https://docs.steel.dev';
@@ -33,6 +34,7 @@ function profileJsonLd({ handle, name, website }: Props) {
url: profileUrl,
mainEntity: {
'@type': 'Person',
+ '@id': getAuthorPersonId(handle),
name,
alternateName: handle,
url: profileUrl,
diff --git a/components/page-jsonld.tsx b/components/page-jsonld.tsx
index 211447ca..925d345c 100644
--- a/components/page-jsonld.tsx
+++ b/components/page-jsonld.tsx
@@ -56,6 +56,7 @@ interface TechArticleProps {
path: string; // canonical path, e.g. /integrations/selenium
datePublished?: string; // YYYY-MM-DD from frontmatter publishedAt
dateModified?: string; // YYYY-MM-DD from git history
+ image?: string; // absolute OG image URL
}
// TechArticle JSON-LD for integration pages. Cookbook recipes emit their own
@@ -67,6 +68,7 @@ export function TechArticleJsonLd({
path,
datePublished,
dateModified,
+ image,
}: TechArticleProps) {
return (
);
diff --git a/components/recipe-jsonld.tsx b/components/recipe-jsonld.tsx
index 658129d6..0e32cfea 100644
--- a/components/recipe-jsonld.tsx
+++ b/components/recipe-jsonld.tsx
@@ -1,4 +1,9 @@
-import { DOCS_URL, getWebPageId, STEEL_ORGANIZATION_ID } from '@/lib/structured-data';
+import {
+ DOCS_URL,
+ getAuthorPersonId,
+ getWebPageId,
+ STEEL_ORGANIZATION_ID,
+} from '@/lib/structured-data';
interface AuthorRef {
handle: string;
@@ -38,8 +43,10 @@ export function RecipeJsonLd({
description,
url: recipeUrl,
mainEntityOfPage: { '@id': getWebPageId(recipePath) },
+ image: `${DOCS_URL}/og${recipePath}`,
author: authors.map((a) => ({
'@type': 'Person',
+ '@id': getAuthorPersonId(a.handle),
name: a.name,
url: `${DOCS_URL}/cookbook/authors/${a.handle}`,
})),
diff --git a/lib/structured-data.ts b/lib/structured-data.ts
index f0133f1d..955054a3 100644
--- a/lib/structured-data.ts
+++ b/lib/structured-data.ts
@@ -8,6 +8,7 @@ export const DOCS_SITE_NAME = 'Steel Docs';
export const DOCS_SITE_DESCRIPTION =
"Documentation for Steel, an open-source browser API for AI agents and automation. Create cloud browser sessions with Steel's APIs, SDKs, and integrations.";
export const STEEL_SAME_AS = ['https://github.com/steel-dev', 'https://x.com/steeldotdev'] as const;
+export const STEEL_LOGO_URL = `${DOCS_URL}/images/logo.png`;
interface WebPageSchemaOptions {
name: string;
@@ -18,6 +19,7 @@ interface WebPageSchemaOptions {
interface TechArticleSchemaOptions extends WebPageSchemaOptions {
datePublished?: string;
dateModified?: string;
+ image?: string; // absolute URL, e.g. `${DOCS_URL}/og/integrations/playwright`
}
export function getCanonicalPageUrl(path: string): string {
@@ -28,6 +30,12 @@ export function getWebPageId(path: string): string {
return `${getCanonicalPageUrl(path)}#webpage`;
}
+// Google reconciles entities on `@id`, not `url`, so the recipe author Person
+// and the ProfilePage Person must share this ID to merge in the Search graph.
+export function getAuthorPersonId(handle: string): string {
+ return `${DOCS_URL}/cookbook/authors/${handle}#person`;
+}
+
export function buildSiteIdentitySchema() {
return {
'@context': 'https://schema.org',
@@ -37,6 +45,7 @@ export function buildSiteIdentitySchema() {
'@id': STEEL_ORGANIZATION_ID,
name: 'Steel',
url: STEEL_URL,
+ logo: STEEL_LOGO_URL,
sameAs: STEEL_SAME_AS,
},
{
@@ -72,6 +81,7 @@ export function buildTechArticleSchema({
path,
datePublished,
dateModified,
+ image,
}: TechArticleSchemaOptions) {
const data: Record = {
'@context': 'https://schema.org',
@@ -85,5 +95,6 @@ export function buildTechArticleSchema({
if (description) data.description = description;
if (datePublished) data.datePublished = datePublished;
if (dateModified) data.dateModified = dateModified;
+ if (image) data.image = image;
return data;
}
diff --git a/tests/structured-data.test.ts b/tests/structured-data.test.ts
index 5a87d291..a39fbad6 100644
--- a/tests/structured-data.test.ts
+++ b/tests/structured-data.test.ts
@@ -1,11 +1,14 @@
// ABOUTME: Contract tests for shared schema.org entity IDs and pure JSON-LD builders.
// ABOUTME: Prevents disconnected entities, unverified profiles, and invented freshness.
import { describe, expect, test } from 'bun:test';
+import { AuthorProfile } from '@/components/author-profile';
+import { RecipeJsonLd } from '@/components/recipe-jsonld';
import {
buildSiteIdentitySchema,
buildTechArticleSchema,
buildWebPageSchema,
DOCS_WEBSITE_ID,
+ getAuthorPersonId,
getWebPageId,
STEEL_ORGANIZATION_ID,
STEEL_SAME_AS,
@@ -22,6 +25,7 @@ describe('structured data builders', () => {
'@id': STEEL_ORGANIZATION_ID,
name: 'Steel',
url: 'https://steel.dev/',
+ logo: 'https://docs.steel.dev/images/logo.png',
sameAs: STEEL_SAME_AS,
});
expect(website).toMatchObject({
@@ -79,5 +83,45 @@ describe('structured data builders', () => {
dateModified: '2026-07-30',
});
expect(article).not.toHaveProperty('datePublished');
+ expect(article).not.toHaveProperty('image');
+ });
+
+ test('carries the absolute article image only when provided', () => {
+ const article = buildTechArticleSchema({
+ name: 'Run Playwright on Steel Cloud Browsers',
+ path: '/integrations/playwright',
+ image: 'https://docs.steel.dev/og/integrations/playwright',
+ });
+
+ expect(article.image).toBe('https://docs.steel.dev/og/integrations/playwright');
+ });
+
+ test('gives recipe authors and profile pages the same stable Person ID', () => {
+ expect(getAuthorPersonId('junhsss')).toBe(
+ 'https://docs.steel.dev/cookbook/authors/junhsss#person',
+ );
+
+ // Call the components as plain functions and parse their emitted JSON-LD:
+ // both Person entities must share the exact @id for Google to merge them.
+ const recipeElement = RecipeJsonLd({
+ slug: 'scrape',
+ title: 'Scrape JavaScript-Rendered Pages to Markdown',
+ description: 'Scrape pages to Markdown.',
+ authors: [{ handle: 'junhsss', name: 'Jun Ryu' }],
+ }) as { props: { dangerouslySetInnerHTML: { __html: string } } };
+ const recipe = JSON.parse(recipeElement.props.dangerouslySetInnerHTML.__html);
+
+ const profileElement = AuthorProfile({
+ handle: 'junhsss',
+ name: 'Jun Ryu',
+ avatar: 'https://github.com/junhsss.png?size=40',
+ }) as { props: { children: { props: { dangerouslySetInnerHTML: { __html: string } } }[] } };
+ const profile = JSON.parse(
+ profileElement.props.children[0].props.dangerouslySetInnerHTML.__html,
+ );
+
+ expect(recipe.author[0]['@id']).toBe('https://docs.steel.dev/cookbook/authors/junhsss#person');
+ expect(profile.mainEntity['@id']).toBe(recipe.author[0]['@id']);
+ expect(recipe.image).toBe('https://docs.steel.dev/og/cookbook/scrape');
});
});