So we keep hitting an SEO check that sounds trivial until we open View Source: the rendered page looks fine, but the raw HTML never got the <link rel="canonical"> we thought we shipped. Nine times out of ten we were either patching <head> after JavaScript ran, or Firebase Hosting was handing back the same bootstrap document for every URL.
What follows is the kind of field notes we scribble while debugging. This is not a victory lap, and not theory for its own sake. We walk through how Next.js emits <head>, what Firebase Hosting does when it rewrites or collapses routes, and why middleware is great for URL hygiene but rarely substitutes for real HTML when someone insists on reading the first response byte-for-byte.
What we’ll learn
By the end we should be able to:
- Say why View Source (
Ctrl+U/ “view page source”) sometimes misses<link rel="canonical">even when DevTools looks fine. - Pick between SSR, SSG, middleware, and hosting without thumb-rules we cannot defend.
- Land on a Firebase shape where each URL’s first HTML already carries the canonical we want.
Prerequisites
- Next.js App Router (
app/). - A Firebase project with Hosting wired up.
- A hard requirement: canonical shows up in the initial HTML (before hydration). Passing Lighthouse is not the same contract.
Stack label: we wrote this assuming Next.js 18 + Firebase. If package.json disagrees on the major, we double-check the Metadata API surface. Patterns below still center on App Router and Metadata.
Why View Source argues with the tab
Crawlers and grouchy debugging sessions treat the raw document as ground truth. If the canonical arrives only via:
- client components,
- client-only head helpers,
- or a single-page shell that serves one
index.htmlfor every path,
then View Source reads “wrong” even while live DOM tricks look right after JS executes.
Rule of thumb: emit canonicals from server-rendered HTML or prebuilt HTML per route. Treat client-side afterburners as a bonus, not the proof.
Decision tree (picking an approach)

Branch A – SSR (dynamic HTML per request)
When: auth, fast-moving content, or routes we cannot freeze at build time.
What we wire up:
generateMetadata({ params, searchParams }), or a staticmetadataexport, so Next prints<link rel="canonical" href="...">into server HTML.
Firebase:
- Hosting must rewrite to something that actually runs Next SSR: Cloud Run, Cloud Functions, Firebase’s framework-aware flows, whichever we deployed. Names change; the invariant does not: the SSR handler returns the full document for that URL.
- Green light:
curlthe page and the canonical shows in the first HTML payload.
Branch B – SSG (pre-rendered per route)
When: the payload is known at build time (or ISR) and caching matters.
What we wire up:
- Same Metadata API, except the HTML ships from
next build(or ISR) so Hosting serves a real file per path with canonicals baked in.
Firebase:
- Works when we deploy distinct HTML for distinct URLs. If every path resolves to the same shell bytes, we are not on this branch; we are still fighting the SPA fallback below.
Branch C – Middleware
Middleware is not our default canonical tag engine.
It shines when we:
- issue 301/308 redirects for duplicates (
http -> https,www, slash policy), - force one preferred URL.
When multiple URLs stay deliberately reachable, we still lean on rel="canonical" unless we have a deliberate redirect-only policy.
Workflow we like: fix SSR/SSG metadata first, then layer middleware for normalization.
Branch D – Hosting configuration (Firebase gotcha)
React may be innocent; Hosting might not be.
We lose the View Source fight when:
- SPA rewrites return one bootstrap HTML for everything.
- Slug-specific canonicals exist in our mental model, but the wire bytes stay identical.
Repair: each meaningful URL must resolve to HTML that already contains the matching canonical through SSR, SSG, or another honest server-render pipeline, not only client glue on a shared shell.
Implementation checklist (what we actually run through)
1) Make canonicals absolute
Relative tags confuse every party (/page vs https://example.com/page). Pick one plan:
- set
metadataBase(App Router) and keep canonical paths tidy, or - emit fully absolute URLs every time.
Sanity pass together:
- TLS host matches prod (
https). wwwvs apex matches the brand rule.- no stray
localhostin production env reads.
2) Prefer the App Router Metadata API
Dynamic routes ([slug], etc.):
- return
alternates.canonicalfromgenerateMetadata.
That pattern usually survives View Source if the route is actually rendering on the server path we think it is.
3) Verify raw bytes, not Elements alone
DevTools reflects post-hydration DOM. View Source does not.
Snippet we keep handy:
curl -sL "https://OUR_DOMAIN/our/page" | tr '\n' ' ' | grep -Eo '<link[^>]*rel=["\"]canonical["\"][^>]*>'
Swap in any parser we trust. The goal is unprocessed bytes, not a live MutationObserver fantasy.
4) Line Firebase up with how Next renders
- SSR behind rewrites: prove the function responds with
<head>metadata per route. - Static export: canonical must exist in each exported HTML artifact; SPA-style edge routing brings us back to Branch D trouble.
Document the pipeline in one blunt sentence so we guessing:
“Firebase Hosting routes
/*to ___ where Next SSR executes.”
FAQ / gotchas
- Rich Results / Lighthouse cheer, View Source boo.
Compare network HTML to post-hydration DOM. If they diverge, we have not met the bar for this article’s definition of “fixed”. - Canonical shifts after in-app navigation.
Client-managed head updates may be fine for UX; audit scripts that insist on first paint will still complain. searchParamssprawl.
Decide how we treat UTMs, facets, and parameterized duplicates. Canonical policy is part product, part SEO hygiene.

Leave a Reply