Last week a portfolio disappeared from Google. Not a broken one — a good one. Published, complete, a real bio, real projects, the kind of page search engines are supposed to love. Google Search Console had a one-line explanation: "Page cannot be indexed: Excluded by 'noindex' tag."
The page was telling Google to go away. The strange part: nobody had asked it to.
Why a portfolio would ever say "noindex"
Start with the part that's working as designed. Showcify hosts a lot of portfolios, and not all of them are ready for search. A page with a two-word bio and no projects doesn't help whoever lands on it, and a flood of thin pages quietly drags down the credibility of every good portfolio we host. So before we let Google index a portfolio, it has to clear a quality bar.
That bar lives in one small service — IndexReadinessChecker — which collapses eight independent conditions into a single yes/no:
published? has an avatar?
opted in to indexing? bio long enough?
has a published project? project descriptions substantial?
no duplicate slug? no duplicate title?
If all eight pass, the page is fair game for search. If any one fails, the portfolio still renders for anyone with the link — but it ships a <meta name="robots" content="noindex, follow"> so Google keeps it out of results. The same definition feeds the sitemap, so the two surfaces can never disagree about what "ready to index" means.
For our missing portfolio, seven of the eight obviously passed. It was published. It had an avatar, a 340-character bio, three projects with genuinely detailed descriptions. So we went looking for the eighth.
Eliminating until one condition is left
The nice thing about a gate made of independent checks is that you can debug it like a logic puzzle. We pulled the live data and walked the list:
- Published, opted in, avatar, bio, projects, descriptions — all comfortably green.
- Duplicate slug? Impossible. The database has a unique index on the slug of every published portfolio, so two live pages physically cannot collide there.
That left exactly one suspect: duplicate title. And the moment we looked at the account, the shape of the bug appeared. The user had two portfolios. The live one was titled with their name. So was the other.
The assumption that did the damage
Here is the check that flagged it, simplified:
def duplicate_title?
Portfolio.where(user_id: user_id)
.where.not(id: id)
.where(title: title)
.exists?
end
Read it adversarially and the problem jumps out. It asks "does this user have any other portfolio with the same title?" — and it does not care whether that other portfolio is published.
The second portfolio was a private draft. It wasn't public. It could not possibly create a duplicate-content problem in Google, because Google had never seen it. But the gate counted it anyway, decided the live page was the duplicate, and stamped it noindex.
Now add the detail that turns a rare edge case into a common one: the default title for a new portfolio is the owner's name. Spin up a second portfolio to try a different theme, leave it as a draft, and you now have two portfolios both called "Jane Doe." One of them is your real, public, carefully-built page. The gate sees the collision and quietly de-indexes it.
It gets worse. When both portfolios were published with the same title, the old check flagged both — neither could be treated as the original, so the user's name dropped out of Google entirely. A rule meant to prevent duplicate content was erasing the very page it was supposed to protect.
The fix: scope to "older and published"
Duplicate-content rules are really canonical-choosing rules. The job was never "punish collisions" — it's "of several pages saying the same thing, keep one and point the rest at it." The original check did the first half and forgot the second.
The new version treats a portfolio as a duplicate only when an older, published sibling already carries the same title:
def duplicate_title?
scope = Portfolio.published
.where(user_id: user_id)
.where(title: title)
scope = scope.where("portfolios.id < ?", id) if id
scope.exists?
end
Two consequences, both intentional:
- A private draft can never de-index a live page. The
publishedfilter excludes it entirely, no matter which portfolio was created first. - Of two published pages with the same title, only the newer one yields. The earliest-created stays canonical and keeps its place in search; the copy steps aside. Exactly one survivor, never zero.
We use the row's id as the tiebreaker because it's monotonic, never null, and never ties — a dependable proxy for "created first."
Locking it in
A fix you can't reproduce is a fix that comes back. Before shipping, we wrote the test that would have caught this the first time: create a published portfolio, give it an older same-title draft, and assert the live page is still indexable. Then its twin — two published copies with one title, assert that only the newer one is flagged. The old behaviour fails both assertions; the new behaviour passes them. The next engineer who "simplifies" this gate now has to get past those two tests, which is exactly the point. The bug is no longer a story — it's a tripwire.
What we took away from it
Three lessons, in increasing order of how often we'll reuse them.
De-indexing logic has to respect visibility. Anything that can pull a page out of search must ask "is the thing I'm comparing against even public?" A draft, a soft-deleted row, a private record — none of them belong in a duplicate-content calculation, because the search engine can't see them either.
Resolving a duplicate means choosing a winner, not nuking the tie. If your rule can fire on two rows at once and silence both, it isn't a canonical rule — it's a self-inflicted outage. Guarantee exactly one survivor.
Watch your defaults for collisions. The nastiest ingredient here wasn't the SQL — it was that a brand-new portfolio is named after you by default. A sensible default quietly guaranteed that the "rare" duplicate-title case would happen to ordinary users doing an ordinary thing. When a default value can collide, assume it eventually will, and make the surrounding logic boring about it.
The portfolio is back in Google now. The page never changed — we just stopped a draft nobody could see from speaking on its behalf.
— The Showcify team
