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
Related
- The Page You Wrote by Hand Is the One That Stops Improving
- Distribute Custom Tools as Composite GitHub Actions (Without npm Publishing)
- What to Delegate and What Not to Delegate to Local LLMs (with Benchmarks)
I publish the configuration for splitting Claude Code into separate personas —
Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run
./setup.sh, and it works. It does not depend on your tech stack.
https://github.com/quintetkit/quartet
I built one real tool using nothing but this workflow. Every Issue, PR, review
and merge is still there. The parts that went wrong were not deleted.
https://github.com/quintetkit/mdlinkcheck
The version that adds a UI Designer persona, review criteria, a per-Issue
parallel execution script and a 10-chapter guide is on the
product page.
The full kit — five personas, the scripts and the complete guide — is available here.
Top comments (0)