131 URLs Google still remembered after the slug scheme changed
GSC's coverage drilldown surfaced 131 stale URLs from before our slug scheme changed. Each one was a lost backlink. Two redirect patterns reclaim them all.
GSC's coverage drilldown today gave me a number I didn't expect. 131 URLs marked as 404. Each one had been crawled and ranked by Google at some point. None of them resolve on the live site anymore.
Investigation took twenty minutes. Every single one was a stale URL Google had remembered from a previous slug scheme. Two patterns showed up.
Pattern A (88 URLs). Shape /videos/<slug> with no studio segment. This is from before SPEC.md's studio-namespaced URL change. All 88 are vrlatina, the only studio in the catalog at the time those URLs got crawled. Live equivalent today is /videos/vrlatina/<slug> for the same row.
Pattern B (42 URLs). Shape /videos/czechvr-fetish/czech-vr-fetish-237-sweet-pee. This is from before scraper-czechvrnetwork build 0.0.5+ flipped the slug format. The old format glued the studio prefix onto the front (czech-vr-fetish-237-sweet-pee); the current format puts the title first and the episode number at the end (sweet-pee-237). Same scene, same row, different slug. Affects all 5 Czech VR network studios.
(One 404 was for https://img.pornboxd.com/, a CDN root request, not a content URL. Out of scope.)
It's tempting to dismiss 131 lost URLs as a small number. That dismissal is the trap. Each one had spent some non-zero amount of time on a SERP, picking up click data and the occasional inbound link from a forum or aggregator. When Google's crawler hits a 404 on a URL it already ranks, the page slowly drops out of search results and any inbound link authority decays with it. Reclaiming those URLs with 301s is a strict upgrade. The cost is one redirect handler. The reward is the recovered authority of every URL that comes back.
The fix is a stateless redirect resolver at apps/api/src/routes/redirects.ts. One new endpoint, GET /api/v1/redirects/video?path=<full-path>, that tries two strategies in order.
Strategy one: single-segment lookup. Take the slug from the path, look in the videos table for any row whose slug matches, tiebreak on the oldest-created row (because slug collisions across studios are real, and the earliest row is the one Google saw first). If a match exists, redirect to /videos/<studio_name>/<slug>. If no match, fall through.
Strategy two: Czech VR prefix-style. Regex match ^/videos/(czechvr-fetish|czechvr-casting|czechvr|czechar|vrintimacy)/czech-(?:vr-fetish|vr-casting|vr|ar|intimacy)-(\d+)-(.+)$, reformat the slug to {title}-{NNN}, verify the row exists in the DB, redirect.
If neither strategy resolves, return 404. Never 301 to a dead target. That rule matters because a 301 chain pointing at a dead URL is worse than a single 404 (Google treats it as a worse signal of site health).
The wiring on the frontend side took two new files. /videos/[studioSlug]/page.tsx catches Pattern A (single-segment URLs that previously 404'd in this static-export route tree). The existing /videos/[studioSlug]/[videoSlug]/page.tsx got a fall-through that fires only when the primary getVideo() returns null, catching Pattern B without touching the hot path.
Both call permanentRedirect() from next/navigation, which emits HTTP 308. Google treats 308 and 301 as equivalent permanent-redirect signals. The 308-vs-301 distinction is cosmetic for SEO. I had to look this up to convince myself.
There was one bug worth telling. Next.js forbids different dynamic param names at the same path depth. My new file was originally /videos/[slug]/page.tsx, and the existing nested directory was /videos/[studioSlug]/[videoSlug]/page.tsx. At runtime, Next.js threw [Error: You cannot use different slug names for the same dynamic path ('slug' !== 'studioSlug').] and 500'd every /videos/* request. Build was clean. The error fires at runtime, not build-time. Hotfix was renaming my new file to [studioSlug]/page.tsx, matching the param name on its sibling in the nested route.
This kind of error class is the worst to debug. The build passes. The static export passes. The deployment goes green. And then every page on the site that touches /videos 500s for visitors. Twenty minutes of red traffic before I noticed. Pinned to .claude/rules/web-frontend.md so future-me doesn't relearn it.
Verification on prod after the deploy:
- Pattern A:
curl -sI https://pornboxd.com/videos/keep-the-lights-onreturns308 Location: /videos/vrlatina/keep-the-lights-on. Five sample slugs, all good. - Pattern B:
curl -sI https://pornboxd.com/videos/czechvr-fetish/czech-vr-fetish-237-sweet-peereturns308 Location: /videos/czechvr-fetish/sweet-pee-237. Five samples across all 3 Czech VR studios in the 404 list. - A made-up legacy URL:
curl -sI https://pornboxd.com/videos/this-never-existed-anywhere-1234returns 404. No false redirects. - A live URL hot path:
curl -sI https://pornboxd.com/videos/vrlatina/keep-the-lights-onreturns 200. The fall-through doesn't interfere with normal traffic. - Sitemap regression check: 31,392 URLs in
sitemap-videos.xml, same as before deploy.
Google's next crawl of those 131 URLs will follow the 308s back to live pages, and the SERP entries flip from 404 to (eventually) re-ranked over the next 4 to 8 weeks at typical crawl cadence.
Memory rule: a URL change is two writes. The new URLs in your sitemap, and a redirect resolver for every URL that ever existed under the old scheme. Skipping the second write doesn't break anything visible. It just slowly leaks SEO authority you spent months earning, and you don't notice until GSC's coverage drilldown catches up to you.