I maintain three long-form pages that already emit JSON-LD: a format guide typed as Article, and two technical write-ups typed as TechArticle. Each page also sets a 1200×630 og:image through the site's shared social-metadata helper.
A weekly structured-data pass, recorded in the commit this post is based on, found that none of the three JSON-LD nodes had an image property. The picture was already on the page. It just was not in the article markup.
What the docs recommend
I opened Google's Article structured data documentation while writing this. A few points matter, and I want to quote them as recommendations, not as guarantees:
- There are no required properties.
imageis in the recommended list: a representative image, as a URL orImageObject. - Image URLs must be crawlable and indexable, and "for best results" Google recommends high-resolution images of at least 50,000 pixels when width is multiplied by height, ideally in 16×9, 4×3, and 1×1.
- Google does not guarantee that features which consume structured data will appear in search results.
The 1200×630 social image is 756,000 pixels, which clears the 50,000-pixel floor. The site still serves only that one picture, not the three aspect ratios in the "best results" note. I am not claiming this change produces a rich result.
The actual gap
The social image and the JSON-LD lived in two places. OG_IMAGE in social-metadata.ts already described the file:
export const OG_IMAGE = { url: "/og.png", width: 1200, height: 630, alt: "Mailward — open PST, OST, OLM, MBOX and EML archives in your browser, nothing uploaded", }; (The export is new. Until this change, the object was file-private, because only the Open Graph tags needed it.)
The article nodes already had headline, dates, author, publisher, and mainEntityOfPage. They did not have image. An og:image tag does not fill that property for you.
One helper, so the two cannot drift
articleImage() turns the same relative path into an absolute URL, which is what the JSON-LD field needs:
import { OG_IMAGE } from "./social-metadata"; /** The articles' Article/TechArticle `image`: the same 1200×630 picture * their og:image already shows, as an absolute URL. */ export function articleImage() { return getCanonicalUrl(OG_IMAGE.url); } getCanonicalUrl prefixes the path with NEXT_PUBLIC_BASE_URL, or with the site's default origin. For /og.png that becomes an absolute URL on that origin.
Each of the three pages then adds one field:
const jsonLd = { "@context": "https://schema.org", "@type": "Article", // or "TechArticle" on the other two pages headline: TITLE, // … image: articleImage(), mainEntityOfPage: getCanonicalUrl("/email-archive-formats"), }; If the social image path ever changes, the structured data follows it, because both read OG_IMAGE. That is the point of the helper. Duplicating the string "/og.png" in seo.ts would have closed the gap today and invited it to open again later.
What the test pins — and what it does not
The new test in tests/structured-data.test.ts checks three things:
-
articleImage()equalsgetCanonicalUrl("/og.png") -
OG_IMAGE.width * OG_IMAGE.heightis at least 50,000 - each of the three page files contains the literal
image: articleImage(),
I read that test. I did not run the suite for this post. The test does not parse rendered HTML or call Google's Rich Results Test; it pins the source and the pixel arithmetic.
Two other limits I do not want to blur:
- The Google page I opened lists
Article,NewsArticle, andBlogPostingas the types Article markup should be based on. Two of these pages useTechArticle. I am not treating that as the same documented type. - The shared social image is a site-wide Open Graph picture, not a photograph unique to each article. The docs prefer an image that represents the article rather than a logo. I did not re-evaluate whether this file meets that wording.
The commit also moves those three pages' sitemap dates, because the page files changed. I am not making a separate claim about how Google treats lastmod.
Takeaways
- An
og:imageand an Articleimageare different slots. Filling one does not fill the other. - If the two should be the same picture, give them one source. A helper that reads the social-image constant is cheaper than a comment saying "keep these in sync."
- Use an absolute URL in the JSON-LD. The Open Graph path can stay relative;
getCanonicalUrlis enough to derive the other. - Pin the URL, the 50,000-pixel floor, and the call sites. That is what will regress, not a screenshot of a rich result that may never appear.
One of the three pages is the email archive formats guide. Disclosure: I built it.
Source note (2026-11-11): I read Mailward commit b37ee2c (AuthorDate 2026-11-11 09:27:13 +0800), the current articleImage() in src/lib/seo.ts, the exported OG_IMAGE in src/lib/social-metadata.ts, the three article pages' JSON-LD objects, and the new assertions in tests/structured-data.test.ts on the Mac. I also opened Google's Article structured data documentation the same day. I did not run the test suite or Google's Rich Results Test.
