We un-hid our hidden SEO text on 24 pages, then found it again with one line of console
CogniPrep has 24 provider hub pages, one per assessment provider. Each of them used to end with a block like this: {/* Hidden AI-crawlable content - not rendered visually */} <div aria-hidden="true" className="sr-only
CogniPrep has 24 provider hub pages, one per assessment provider. Each of them used to end with a block like this:
{/* Hidden AI-crawlable content - not rendered visually */}
<div aria-hidden="true" className="sr-only">
<h2>About CogniPrep Hogan Practice</h2>
<p>CogniPrep provides independent simulations of {games.length} Hogan assessments...</p>
<ul>
{games.map((game) => (
<li key={game.id}>{game.name}: {game.description} ({testMeta(game)})</li>
))}
</ul>
</div>
sr-only hides it from sighted users. aria-hidden="true" hides it from screen readers. Together they hide it from every human being, which leaves search engines as the only intended audience, which is the textbook definition of the thing search engines penalise.
I have written about finding that before. This post is about the part I found more interesting, which is that removing it was not a one line change, and that the cleanup was not as complete as the diff made it look.
Un-hiding text is a content change, not a CSS change
Delete the class and you do not get a paragraph, you get a mess. The block was written for a crawler, and it shows in three ways.
It had no styling, because nothing rendered it. A bare <ul> in a Tailwind project renders as unstyled lines with no bullets and no indentation. Every element needed real classes before it could be shown to anyone:
{/* About the practice on this hub. Rendered on purpose: it used to be a
screen-reader-only block, and hidden text is a spam-policy risk. */}
<div className="mb-16">
<h2 className="mb-6 text-2xl font-bold md:text-3xl">About CogniPrep Hogan Practice</h2>
<p className="text-muted-foreground mb-4 text-sm leading-relaxed">...</p>
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm leading-relaxed">
It repeated the page. Copy written for a machine says everything, because a machine does not get bored. Read aloud after the section that already lists every test, half of it is redundant, and the redundancy is only tolerable when invisible.
It needed somewhere to live. This is the cost nobody budgets for. The bottom of that page already had a call to action and a trademark notice. Adding 400 words of real content underneath meant the page ended with three separate closing statements, which is a page with no ending at all. The call to action was deleted, not moved. A hub page whose whole job is sending you into a provider's practice tests has links to those tests in cards above, and a second "Ready to start practising?" panel was there for the same reason the hidden text was: it felt like something a page should have.
So the work was: style it, cut it down, decide what it replaces. That is editing, and it does not go faster because the underlying cause was a CSS class.
The other half: links the crawler could actually follow
The same pass added a component that solves the sibling problem. Search Console had the provider tips posts and the entire "can you cheat this test" cluster sitting in Discovered, currently not indexed for weeks. Those pages were in the sitemap. Nothing that already ranked linked to them, so they never earned a crawl.
The hubs are the highest impression pages on the site, so they are where the links belong:
export function ProviderGuides({ provider, heading, intro }: ProviderGuidesProps) {
const posts = BLOG_POSTS.filter((post) =>
post.relatedGames?.some((id) => getGameById(id)?.provider === provider)
)
.sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 5);
const cheating = getCheatingGuide(provider);
if (!cheating && posts.length === 0) return null;
// ... renders a card grid
}
The part I would defend in review is that nothing is hand maintained. A post already declares which games it relates to, and a game already knows its provider, so "which guides belong on this hub" is derived by composing two facts that exist for other reasons. A curated list of links per provider would be 24 lists, all correct on the day they were written, and all quietly wrong three posts later.
if (!cheating && posts.length === 0) return null matters more than it looks: the section cannot render as an empty heading over empty space on a provider that has no guides yet. Derived sections need an empty state, and for a link block the right empty state is not existing.
Across the 24 hubs it currently produces an average of 4.1 links each, and it took no editorial work at all.
Then I checked the actual DOM
The commit changed 24 page files. The hidden blocks it fixed were the ones written in those page files. So after it shipped, I ran the check that found the problem in the first place, against every hub in a real browser:
document.querySelectorAll(".sr-only[aria-hidden='true']").length
21 of the 24 hubs still return 1. Each one is a block of roughly 1,400 characters, and it opens "Which companies use Aon / cut-e?".
It is not a page file block. It lives in a shared component that lists employers known to use a provider, and that component ends with this:
{/* Hidden AI-crawlable summary */}
<div aria-hidden="true" className="sr-only">
<h3>Which companies use {label}?</h3>
...
</div>
One component, imported by 23 of the 24 pages. The audit was a page audit, the fix was a page fix, and the survivor was one import away the whole time. The three hubs that come back clean are not clean because anyone fixed them, they are clean because those providers publish no employer list at all for unrelated editorial reasons.
The lesson is not "grep harder". It is that an audit should read the rendered DOM, not the source tree, because the DOM is the only artifact that does not care which file the markup came from. A grep -r "sr-only" over app/ would also have missed this. A selector run against 24 live URLs takes about ninety seconds and cannot miss it.
It is still there at the time of writing. That is the honest status: the component level fix is queued, the page level one shipped, and the gap between them existed because the check ran on the wrong artifact.
See it
- cogniprep.app/games/hogan: scroll to the bottom. The "About CogniPrep Hogan Practice" section is the text that used to be invisible, and "Guides and Tips" above it is the derived link block. Nothing on that page is hidden.
-
cogniprep.app/games/aon: open the console and run the selector above. You will get
1, anddocument.querySelector(".sr-only[aria-hidden='true']").textContentwill show you the survivor described here. This is the most honest kind of demo: the bug is still in the page you are reading about. - Try the same selector on your own site. If it returns anything, read what comes back and ask who it was written for.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.