I shipped a content site in a niche most devs don't build for — adult-lifestyle product reviews. The stack is boring on purpose: FastAPI + Jinja2 + JSON data files, served from Fly.io's free tier behind Cloudflare. 68 pages, zero CMS, zero build step.
Two months in, Google Search Console started emailing me something that explained everything about my dead traffic:
"Duplicate without user-selected canonical"
Here's the bug, the fix, and what the data looked like before and after.
The stack
- FastAPI + Jinja2Templates — server-side rendering, no JS framework
-
Content as JSON —
hubs.json,categories.json,articles.json, versioned in git - Standalone Jinja2 templates per pillar page, plus a few pre-rendered static HTML files
-
Fly.io free tier + Cloudflare, with a Python script that regenerates a static
sitemap.xml
Why JSON + templates instead of a CMS? Because I wanted content in version control and zero database to babysit. 68 pages, no admin panel, no migration anxiety.
The bug that was tanking my rankings
I opened one of the flagged pages and found two canonical tags:
<link rel="canonical" href="https://safeadultguide.com/hub/bdsm-gear-complete-guide">
<link rel="canonical" href="https://safeadultguide.com/">
The second one pointed at the homepage. I'd left a "default canonical" line in the base template's <head> and forgotten it existed. Every article on the site was telling Google: "my canonical page is the homepage."
Google did exactly what I asked it to: treated all 68 pages as duplicates of the homepage, collapsed their ranking signals, and parked me at an average position of 77.5 — page 8 of results. Zero clicks across ~230 impressions.
The fix
Move the default canonical inside the Jinja2 block so child templates can override it:
{# base.html #}
{% block canonical %}
<link rel="canonical" href="https://safeadultguide.com{{ request.url.path }}">
{% endblock %}
Now:
- Pages with no override get a correct self-canonical
- Pages needing a fixed URL override the block
- Exactly one canonical per page
But a second bug was hiding behind the first. Four of my "hub" pages were served as pre-rendered static HTML files via open().read() — which bypasses Jinja2 entirely. My template fix didn't touch them; they still had the broken canonical baked into the file. I had to patch those four files individually.
Lesson: if you mix template-rendered and pre-rendered static pages, a template fix silently does NOT propagate to the static ones.
Before and after
- Before: ~230 impressions / 2 weeks, 0 clicks, average position 77.5
- ~1 week after deploy: impressions up ~35%, and the first click landed
It's a slow climb — Google has to re-crawl 68 pages — but the direction finally flipped from "invisible" to "climbing."
What I'd tell past me
- Topic clusters beat thin sites. 3–5 pillar articles (3,000+ words) + 30–50 cluster articles beats 68 disconnected pages. Google rewards depth over sprawl.
- Cloudflare "Always Use HTTPS" only works if DNS records are proxied (orange cloud). Grey-cloud means traffic bypasses Cloudflare, and http/https serve duplicate content — feeding the exact "duplicate" flag above.
-
Fly.io's Jinja2 has a dict-hashing bug with
TemplateResponse()— wrap it in arender()helper or it crashes on deploy. - Mine GSC query data to pick your next article. Write for keywords already earning impressions, not a random list.
-
Check your canonical tags right now. One stray
<link>in a shared layout can silently de-index every page you own.
You can see the site here: safeadultguide.com — and if you run a content site, go grep your own <head> for duplicate canonicals. It's a 30-second check that might be costing you page one.
Top comments (0)