2026-09-05 · seo · webdev · python · softwaredevelopment
Publishing the Same Article in Three Places Splits Its Search Value
I publish technical articles to Zenn, to dev.to, and to my own site. Same content. Done naively, that leaves Google looking at the same article three times.
Which of the three appears in search results is then Google's decision, not mine. Left to itself, that decision usually goes the way I do not want.
What the actual problem is
"Duplicate content is penalised" is a common phrasing and not quite right. When Google finds duplicates it picks one and drops the rest from results. That is selection, not punishment.
The problem is that I had not decided which one wins.
Suppose my own site wins. It is a new domain with no authority. Zenn and dev.to will outrank it on any query. If the weakest copy is chosen as canonical, nothing shows up at all.
And if a stronger one wins while I do nothing, my own page is treated as the duplicate, and whatever value would have accrued to it goes nowhere.
Declare the original with canonical
<link rel="canonical"> states where a page's original lives.
<link rel="canonical" href="https://zenn.dev/user/articles/slug">
You can point it at a different domain. It means: this page is a copy, please consolidate onto that one.
Point from the weak domain to the strong one — from my own site to Zenn or dev.to.
It feels like giving something away, and it is the opposite. Value split three ways consolidates into one place, which is the only version with a chance of ranking. Owning three pages that never appear is not better than owning one that does.
Managing it in practice
The catch is that the canonical target only exists after publishing. A Zenn URL is not knowable until the article is live there.
So it does not live in the article's frontmatter. It lives in a separate file:
{
"ja": {
"multi-persona-workflow": "https://zenn.dev/user/articles/multi-persona-workflow"
},
"en": {
"multi-persona-workflow": "https://dev.to/user/some-slug-with-hash"
}
}
Keeping it out of frontmatter is deliberate. That frontmatter is handed verbatim to Zenn and dev.to. I did not want to add keys of my own to a structure that another platform parses and may reject.
The generator reads the table and decides per article:
# An article whose original lives elsewhere does not claim to be the original
self.canonical_override = CANONICAL.get(lang, {}).get(self.slug)
@property
def canonical(self) -> str:
return self.canonical_override or BASE_URL + self.url_path
Drop them from the sitemap too
Pointing canonical elsewhere was not enough on its own.
A sitemap is a request to index a page. Asking for indexing while simultaneously declaring "the original is over there" is saying two different things.
# An article whose original is elsewhere stays out of the sitemap.
# Asking to index something you have declared a copy of only splits the signal.
urls += [(BASE_URL + p.url_path, p.date) for p in all_posts
if p.canonical_override is None]
An article not yet published anywhere else genuinely is original on my site, so it stays in the sitemap until the day it is not. The rule follows publication state automatically.
Why publish to your own site first
One point about ordering.
Waiting for a platform to be ready leaves a window where the article exists and is visible nowhere. Indexing takes weeks, so every day of waiting pushes the payoff back by a day.
So it goes to my own site the moment it is written, and at that point my site is the original. When the platform is ready, the canonical is repointed — which is a one-line data change, not a code change.
Delete the old page when you rename
One more hole in the generator.
Renaming an article's slug produces the new page and leaves the old one in place. Nothing tells it to go.
The result is two live URLs with identical content. Duplicate content I created myself.
# Remove generated pages that no longer have a source
for lang, d in (("ja", SITE / "articles"), ("en", SITE / "en" / "articles")):
keep = {f"{q.slug}.html" for q in posts if q.lang == lang} | {"index.html"}
for f in d.glob("*.html"):
if f.name not in keep:
f.unlink()
A generator that wipes its output directory each run never has this problem. One that writes incrementally always will.
Summary
- Duplication is selection, not a penalty. The problem is not choosing which copy wins
- Point canonical from the weak domain to the strong one. One page that ranks beats three that do not
- The canonical target is only known after publishing, so keep it in a separate file — frontmatter is handed to other platforms and should not carry your own keys
- Drop canonicalised pages from the sitemap. Say one thing, not two
- Publish to your own site first. Indexing takes weeks and waiting only costs you them
- Delete stale output when a slug changes. Incremental generators always miss this
The workflow itself is available
Quartet, the four-persona version, is published free under MIT. Quintet adds a UI Designer persona, review criteria, a per-Issue parallel execution script, and a 10-chapter guide.
See the free version Product page