I cross-post a lot of my writing. Articles go up on freeCodeCamp first, and then I mirror them on my personal site. That's a pretty standard thing to do. You get the reach of a bigger platform and you still own a copy on your own domain.
To handle this correctly from an SEO perspective, I set a canonicalUrl in the frontmatter of each mirrored post. This tells search engines: "hey, the original lives over there, don't treat my version as a duplicate." My Next.js metadata was picking that up properly and emitting the right <link rel="canonical"> tag in the HTML head.
So I thought everything was fine.
It wasn't.
What I Actually Had
When someone opens a cross-posted article on my site, the browser receives two different signals about where the "primary" version of that page lives:
- The HTML
<link rel="canonical">: pointing to freeCodeCamp (correct) - The
BlogPostingJSON-LD structured data:mainEntityOfPagehardcoded to my local URL (wrong)
These two signals were contradicting each other. The canonical tag was saying "freeCodeCamp is the original." The structured data was saying "no, this page is the main entity."
How the Issue Was Spotted
It came up as a GitHub issue (#13) on the repo. The description was specific enough that I could immediately see what was happening without needing to dig around.
The problem was in app/blogs/[slug]/page.tsx. The generateMetadata function was handling canonicalUrl correctly:
return buildNextSeo({
title: `${post.meta.title} | ${SITE_NAME}`,
description: post.meta.excerpt,
path: `/blogs/${post.meta.slug}`,
...(post.meta.canonicalUrl && { canonicalUrl: post.meta.canonicalUrl }),
// ...
});But just a few lines below, the JSON-LD object had this:
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.meta.title,
// ...
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${SITE_URL}/blogs/${post.meta.slug}`, // always local, never canonical
},
};The mainEntityOfPage field was always pointing to my local blog URL, regardless of whether a canonicalUrl existed. The two pieces of code never talked to each other.
Why This Matters
Search engines use both signals. <link rel="canonical"> is the explicit canonical declaration. JSON-LD structured data is the machine-readable layer that tells crawlers about the content's identity, relationships, and authorship.
When those two disagree, you create ambiguity. A crawler trying to figure out "is this the original article or a syndicated copy?" now has conflicting evidence. In practice, most crawlers probably resolve this in favor of the <link rel="canonical"> tag, but "probably" isn't a great SEO strategy. You want your signals clean and consistent.
For syndicated content specifically, the structured data mainEntityOfPage should point to wherever you're claiming canonical authority lives, not just blindly to the current page.
The Fix
One line:
// Before
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${SITE_URL}/blogs/${post.meta.slug}`,
},
// After
mainEntityOfPage: {
"@type": "WebPage",
"@id": post.meta.canonicalUrl ?? `${SITE_URL}/blogs/${post.meta.slug}`,
},When canonicalUrl is present in the frontmatter, the JSON-LD now uses it. When it's absent (for original posts that live only on my site), it falls back to the local URL exactly as before. No behavior change for the majority of posts.
What I Learned
The boring lesson is: when you add a feature (canonical URL support), audit every place that touches the same concept. The generateMetadata function got the canonical right. The JSON-LD block was written separately and never updated when canonical support was added.
The more interesting thing I keep noticing: SEO bugs are quiet. There's no error thrown. The page renders fine. Users don't see anything wrong. The problem only exists in how machines read the page, and machines don't complain. They just silently make suboptimal decisions about your content.
That's what makes them easy to miss and worth being deliberate about. Treat your structured data with the same care as your visible content. If something is true in your <head>, it should be consistent in your JSON-LD too.

