Five CMS Mistakes That Kill Your Google Rankings — I Made All Five

A soft-404 bug, a broken hreflang cluster, NUL-byte legal pages, auto-link spam and infinite ?page=N URLs: five ranking killers I hit on my own 14 sites.

Five CMS Mistakes That Kill Your Google Rankings — I Made All Five

A soft-404 bug, a broken hreflang cluster, NUL-byte legal pages, auto-link spam and infinite ?page=N URLs: five ranking killers I hit on my own 14 sites.

There's a version of this article on every CMS blog: canonicals, alt text, TTFB, the usual suspects. I've written that version too. This one is different. These are the five mistakes that actually cost me rankings — and one AdSense application — on the 14 content sites I run on jekcms. I wrote the CMS myself; the biggest site carries 2,300+ posts and the fleet is somewhere past 5,500. Nowhere to hide.

Each mistake follows the same arc: how I found it, what it was doing to the sites, and the fix that's now baked into the product.

1. The 404 that arrived after the headers left

Search Console started filing URLs under "Soft 404". Not "Not found (404)" — soft 404, which means Googlebot fetched the page, received a 200 OK, and concluded the page was empty anyway. The URLs were deleted or renamed posts. In a browser everything looked right: dead slug, "post not found" message, move on. What a browser doesn't show you is the status line.

The single-post template loaded header.php first and queried the database second. PHP commits the response status with the first byte of output, and header.php prints plenty of bytes. By the time the code discovered the post didn't exist, the 200 was already on the wire and http_response_code(404) had become a line that runs and does nothing:

// the broken order
require 'includes/header.php';   // output starts — status 200 locked in
$post = $db->fetch("SELECT * FROM posts WHERE slug = ?", [$slug]);
if (!$post) {
    http_response_code(404);     // executes, has no effect
    include 'includes/not-found.php';
}

Why it matters: an honest 404 gets dropped from the index and forgotten. A soft 404 teaches Google that your site answers "success" for garbage, which quietly devalues every other 200 you serve. My soft-404 bucket grew with every deleted post, and Googlebot kept re-crawling the corpses because they insisted they were alive.

The fix is an ordering rule, not a clever trick. Resolve the content before emitting a single byte:

$post = $db->fetch("SELECT * FROM posts WHERE slug = ?", [$slug]);
if (!$post) {
    http_response_code(404);
    header('X-Robots-Tag: noindex');
    require 'includes/404.php';
    exit;
}
require 'includes/header.php';   // HTML starts only now

The X-Robots-Tag header is a second lock on the same door: even if something between your server and the crawler mangles the status code, the noindex still lands.

2. Eighty-seven hreflang tags, zero self-references

jekcms runs English and Turkish from one codebase, with localized slugs — the English pricing page is /pricing, the Turkish one is /tr/fiyatlandirma, not /tr/pricing. During a routine pass over the documentation section I viewed source on a random deep page and stared at the hreflang block for a while. Both alternates pointed at the docs landing page. I opened another page. Same pair. All 87 documentation pages declared /docs and /tr/dokumantasyon as their language alternates, because the block was hardcoded in the layout template and nobody had ever questioned it.

Here's what turns sloppy into fatal. hreflang clusters are validated as a set: every page must include a reference to itself, and every alternate must link back. A page sitting at /docs/automation/cron-setup that claims its English version is /docs fails both checks — and Google's documented response to an invalid cluster is to ignore the annotations entirely. So the whole docs section, two languages of near-parallel content, was competing against its own translations with no declared relationship between them. Eighty-seven pages of self-inflicted duplication.

The fix that stuck was a rule: hreflang is data derived from the request path, never markup pasted into a template.

$path = strtok($_SERVER['REQUEST_URI'], '?');
$en = docs_path_to_en($path);   // /tr/dokumantasyon/x  =>  /docs/x
$tr = docs_path_to_tr($path);   // /docs/x  =>  /tr/dokumantasyon/x

echo '<link rel="alternate" hreflang="en" href="' . SITE_URL . $en . '">';
echo '<link rel="alternate" hreflang="tr" href="' . SITE_URL . $tr . '">';
echo '<link rel="alternate" hreflang="x-default" href="' . SITE_URL . $en . '">';

The full slug-mapping approach, localized URLs included, is in the hreflang and canonical strategy write-up.

3. 14,616 bytes of nothing

June 2026, AdSense rejection postmortem. Part of the feedback pointed at site quality, so I crawled my own network the way a reviewer would. Four sites were serving a completely blank privacy page.

The file was there. legal.php, 14,616 bytes, plausible timestamp, no PHP error, HTTP 200. I opened it in a hex viewer and every single byte was 0x00. NUL bytes, all the way down. Fourteen kilobytes of file rendering as nothing — on the one page an ad network reviewer is guaranteed to check.

A blank privacy policy is not a small defect. For AdSense it's a disqualifier on its own; for users it's a trust page that doesn't exist. And nothing had raised an alarm, because every layer I normally rely on saw a healthy file: it existed, its size was reasonable, it parsed (an all-NUL file upsets PHP surprisingly little), and it returned 200.

The same day, the CI pipeline got a guard that fails any deploy containing a corrupt or stub PHP file:

# deploy gate: no NUL bytes, no stub PHP files
if grep -rlaP '\x00' --include='*.php' .; then
    echo "NUL bytes found in PHP files — aborting"; exit 1
fi
find . -name '*.php' -size -32c -not -path './vendor/*' | grep . \
    && { echo "suspicious stub PHP files"; exit 1; }

The lesson generalizes: verify what the server serves, not what the disk holds. curl your own legal pages once in a while. It costs nothing, and it would have caught this months earlier.

This one I did to myself, with a tool I was proud of. I had built an auto-linker: feed it a keyword-to-URL map and it injects internal links across the entire corpus. Internal linking is good for SEO, so more must be better — and I ran it aggressively across the whole network.

Months later, rereading my own posts as a stranger during that same AdSense postmortem, the effect was hard to unsee. The same anchor words linked on every occurrence. Links dropped mid-sentence, pointing at pages that were only vaguely related. Paragraphs where three consecutive lines each carried a link. The posts read like a product catalog wearing a blog's clothes — exactly the machine-generated texture that gets a network flagged, and it contributed to the rejection.

There was no shortcut through the repair. Posts got reworked by hand, and links are now chosen editorially, one at a time. The rules I ended up with are short:

  • A link goes where a reader would actually ask "wait, how?" — not wherever a keyword happens to appear.
  • Anchor text varies; if the same phrase links twice on one page, one of them goes.
  • Never inside headings, never inside code, and rarely more than a handful per post.

The auto-linker still exists, but it has been demoted to a suggester: it proposes, a human decides.

5. ?page=9999, a perfectly valid page

The blog listing took a ?page=N parameter and trusted it. On a seven-page archive, page 7 rendered posts; page 8 rendered the same header, sidebar and footer around an empty list — with a 200. So did page 500. Crawl stats showed Googlebot dutifully walking into that void: an unbounded supply of thin, near-identical URLs, on a fleet that lives on shared hosting, where crawl budget is not a theoretical concern.

Every one of those pages was pure shell. Full layout, zero content, all claiming to be fine.

Out-of-range pagination is now a hard 404:

$perPage = 10;
$total = (int)$db->fetch("SELECT COUNT(*) AS c FROM posts WHERE status = 'published'")['c'];
$last  = max(1, (int)ceil($total / $perPage));
$page  = (int)($_GET['page'] ?? 1);

if ($page < 1 || $page > $last) {
    http_response_code(404);
    require 'includes/404.php';
    exit;   // no shell, no layout — a real 404
}

Combined with a canonical tag that strips query parameters on regular pages, the infinite URL space collapsed back down to the pages that actually exist.

The common thread

None of the five is visible in a browser. All five are visible to a crawler. That asymmetry is the whole story: I looked at my sites the way a visitor does, daily, for years — and still shipped bugs that only an HTTP client reading status lines and head tags would ever see.

The habit that changed: curl -sI against my own URLs, view-source on deep pages, and treating Search Console's coverage report as a bug tracker rather than a dashboard. The broader sweep that grew out of these incidents became its own series — the audit findings post covers what the same methodology turned up at scale.

All five fixes now ship as jekcms defaults. The best place to fix an operator mistake is inside the product, where I can't repeat it.

Written by

Celil Uyanıkoğlu

Computer engineer with 25+ years in IT. He builds jekcms and runs his own network of content sites on it — every guide published here is tried on those live installs first.

See all posts →

Order Today

One-time payment, lifetime access. Setup in 30 minutes.

View Pricing
  • Setup and live in 30 minutes
  • 13 professional themes
  • AVIF/WebP image optimization
  • Automatic SEO — Sitemap, Schema.org
  • ZeroTrack cookieless analytics

Be the first to know

New features, release notes & CMS guides — a couple of emails a month, no spam.