Skip to main content

How I Fixed Canonical URLs Pointing to Localhost in Next.js

How I Fixed Canonical URLs Pointing to Localhost in Next.js
April 5, 2026
10 min read
nextjsseocanonical-urlsgoogle-search-consoledebuggingtechnical-seo
Next.js sites using process.env.SITE_URL || 'http://localhost:3000' as a canonical URL fallback will fail Google indexing entirely. The fix: one centralised getPublicSiteUrl() helper replacing all scattered inline fallbacks. 13 files, 18 lines of code, zero pages indexed before, full indexing restored after.

The one where I made myself invisible to Google

So here's the scene. I've been doing SEO for over a decade. I've ranked pages in competitive niches, recovered sites from algorithmic penalties, diagnosed crawl issues that had entire dev teams scratching their heads. I once figured out a ranking strategy from a Russian forum post that I had to run through three different translators to even begin to understand.

This is literally what I do.

Then I built my own portfolio site. Next.js, clean architecture, proper schema markup, the works. I spent weeks getting every technical SEO detail right because, well, if you're going to put your name on something that's supposed to demonstrate your SEO skills, it better be perfect good enough airtight. Deployed it, submitted to Search Console, cracked open a beer, and waited for Google to do its thing.

Two weeks go by. Nothing indexed. Okay, new domain, Google's slow sometimes. Three weeks.

Still nothing. Maybe the sitemap needs a resubmit? Four weeks in and I start checking things more carefully. Search Console says "Discovered - currently not indexed" on literally every page.

Every. Single. One.

I'm sitting there at midnight with Benji snoring next to me, staring at my screen like it personally betrayed me, and I do what any panicking rational SEO professional would do: I View Source on my own production site.

And there it is. Right there in the HTML. Mocking me:

<link rel="canonical" href="http://localhost:3000/projects/runnerkit">

Localhost. On production.

My canonical URLs were telling Google to go index my living room laptop. Every single page on my site — the one built by a professional SEO consultant someone who should know better a guy who literally does this for a living — was politely asking Googlebot to crawl a URL that doesn't exist outside my local network. For five weeks.

I genuinely considered never speaking of this again not writing this post. But then I thought about all the Next.js developers who are probably staring at the same "Discovered - currently not indexed" status right now, checking their content quality, auditing their backlink profiles, questioning their life choices — when the actual problem is one line of code that works perfectly in development and silently destroys everything in production.

So here we are. My canonical shame, your shortcut to the fix.

The short version: every canonical URL, every og:url, and every sitemap entry pointed to http://localhost:3000 in production. Thirteen files. One copy-pasted line of code. Five weeks of zero organic visibility.

And an 18-line fix that brought everything back.

Quick Diagnosis Checklist

Before diving into the code, run through these checks on your production site. Each one takes under a minute and will tell you exactly where the problem is — or confirm you're clean.

SignalWhat to CheckTool
Canonical tag in production HTMLView Source → search for <link rel="canonical"Browser
og:url meta tagView Source → search for og:urlBrowser
Sitemap URLsOpen /sitemap.xml in browserBrowser
Google's selected canonicalSearch Console → URL Inspection → "Google-selected canonical"GSC
Schema @id and url fieldsView Source → search for application/ld+jsonBrowser
metadataBase resolutionCheck root layout.tsxgenerateMetadata()Code

If any of these show localhost, 127.0.0.1, or a port number, your site is invisible to Google. Full stop.

What Does Google Do When a Canonical URL Points to Localhost?

Google treats http://localhost:3000 as a valid URL. It doesn't throw an error or ignore the tag. It tries to crawl that URL, fails because localhost isn't routable from Googlebot's servers, and then classifies your real page as a duplicate of an unreachable canonical target.

The result in Search Console:

  • Status: "Duplicate, Google chose different canonical than user"
  • Or: "Discovered - currently not indexed"
  • User-declared canonical: http://localhost:3000/projects/my-project
  • Google-selected canonical: None (can't reach it)

This is worse than having no canonical tag at all. With no canonical, Google at least tries to figure out the right URL. With a canonical pointing to an unreachable host, Google gives up.

How Did 13 Files End Up With the Same Bug?

The pattern looked like this, scattered across the codebase:

// This was in 13 different files
const site = (process.env.SITE_URL || 'http://localhost:3000').replace(/\/$/, '');

Every page that generated metadata — blog posts, projects, case studies, the root layout, schema components, even the llms.txt redirect — had its own copy of this line. The intent was reasonable: use the SITE_URL environment variable, fall back to localhost for development.

The problem: SITE_URL wasn't set in production. Not because anyone forgot. The deployment used database-driven settings (site URL stored in a Setting table and read at runtime). But the generateMetadata() functions ran at build time or at request time before the database was available, so they fell back to the hardcoded localhost:3000 default.

Files affected

FileWhat it broke
app/layout.tsxmetadataBase for all pages
app/blog/[slug]/page.tsxBlog post canonical + og:url + share URLs
app/blog/page.tsxBlog listing canonical
app/projects/[slug]/page.tsxProject page canonical + schema url
app/projects/page.tsxProjects listing canonical
app/case-studies/[slug]/page.tsxCase study canonical + schema url
app/case-studies/page.tsxCase studies canonical
components/seo/PageSEOSchemas.tsxJSON-LD @id and url in all schemas
app/.well-known/llms.txt/route.tsllms.txt redirect target

That's 9 unique files (some had the pattern twice — the blog post page had it in generateMetadata(), in the component body for schema generation, and in the share bar URL). Total: 13 occurrences.

Data Experiment: Auditing a Codebase for Scattered URL Patterns

I ran a grep across the full codebase to count how many files independently constructed the site URL:

grep -rn "process\.env\.SITE_URL" --include="*.ts" --include="*.tsx" | grep -v node_modules | grep -v ".next"

Result: 13 matches across 9 files. Every single one had a localhost fallback. None of them used the database-driven URL.

The pattern had propagated through copy-paste. Each new page template started by copying an existing page's metadata generation, including the inline URL construction. Nobody noticed because localhost is correct during development — the bug only manifests in production.

Takeaway: If you grep your Next.js project for process.env.SITE_URL right now and find more than one result, you probably have this bug waiting to happen. The number of occurrences directly correlates with the blast radius when the env var is missing.

What Does the 18-Line Fix Actually Look Like?

One helper function. One source of truth. Replace all 13 scattered patterns.

// lib/utils.ts

/**
 * Single source of truth for the public site URL.
 *
 * Every page, schema, canonical tag, og:url, and sitemap entry
 * should use this instead of rolling its own fallback.
 *
 * Priority: SITE_URL env var → hardcoded production URL (never localhost).
 * Trailing slashes are always stripped.
 */
export function getPublicSiteUrl(): string {
  return (process.env.SITE_URL || 'https://booplex.com').replace(/\/$/, '');
}

The critical difference: the fallback is the production domain, not localhost. If SITE_URL isn't set, the worst case is that canonical URLs point to your real domain. That's the correct behavior.

Then every file gets the same one-line change:

- const site = (process.env.SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
+ import { getPublicSiteUrl } from '@/lib/utils';
+ const site = getPublicSiteUrl();

Why Does metadataBase Matter More Than Individual Canonicals?

In Next.js 13+ (App Router), metadataBase in your root layout sets the base URL for every piece of metadata across the entire site. If metadataBase resolves to localhost, every page inherits that — even pages that don't set their own canonical.

This means og:url, twitter:url, sitemap entries, and JSON-LD url fields all resolve against metadataBase. Fixing the root layout alone fixes the default for every page. But pages that construct their own absolute URLs (like blog posts building ${site}/blog/${slug}) still need the helper.

Both layers need the fix. metadataBase handles relative URLs. getPublicSiteUrl() handles absolute URL construction.

When Is This Fix the Wrong Approach?

If your production URL changes per deployment (staging, preview branches, multiple domains), a hardcoded fallback won't work. You need the env var set correctly per environment. Vercel, Netlify, and similar platforms auto-set VERCEL_URL or equivalent — use that as a secondary fallback:

export function getPublicSiteUrl(): string {
  const url = process.env.SITE_URL
    || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null)
    || 'https://your-production-domain.com';
  return url.replace(/\/$/, '');
}

If you're running a multi-tenant setup where different domains serve different content, a single helper isn't enough. You need request-level URL detection, which means headers() from next/headers — but be aware that calling headers() forces the page to be dynamic (no static generation).

If your site URL is managed through a CMS or database, you need an async version. Our codebase has getSiteUrlCached() for this — it reads the URL from the database with a 5-minute TTL cache. But the synchronous getPublicSiteUrl() is still the right choice for generateMetadata() and schema components that need a fast, guaranteed return.

How Do You Stop This Bug From Coming Back?

Three things I did after the fix:

1. Added a rule to the project's CLAUDE.md (the AI coding assistant's config file):

NEVER use process.env.SITE_URL || '...' inline.
Always import and use getPublicSiteUrl() from @/lib/utils.

This catches it at the AI-assisted coding layer. Every new page template generated by Claude Code uses the helper automatically.

2. Grep check in CI:

# Fails the build if any file has an inline SITE_URL fallback
if grep -rn "process\.env\.SITE_URL.*||" --include="*.ts" --include="*.tsx" \
   | grep -v "lib/utils.ts" | grep -v node_modules; then
  echo "ERROR: Use getPublicSiteUrl() instead of inline SITE_URL fallback"
  exit 1
fi

3. Production smoke test: After deploy, curl -s https://booplex.com | grep -o 'localhost' should return nothing. If it returns matches, the deploy is broken.

How Long Did Full Google Re-Indexing Take?

DateEvent
~Feb 2026Site launched with 13 inline SITE_URL fallbacks
Mar 15Noticed zero indexed pages in Google Search Console
Mar 23Identified localhost in canonical tags via View Source
Mar 23Committed fix: getPublicSiteUrl() replacing all 13 occurrences
Mar 23Deployed, verified production HTML shows correct domain
~Apr 1Google began indexing pages (Search Console showed coverage improvement)

Total time to fix: about 2 hours (including the grep audit and testing). Time lost to the bug: roughly 5 weeks of zero organic visibility.

How Does Next.js 15 generateMetadata Handle URL Resolution?

Next.js resolves metadata URLs in this order:

  1. Absolute URL in metadata → used as-is (this is where the bug hit)
  2. Relative URL in metadata → resolved against metadataBase
  3. metadataBase not set → Next.js tries to infer from VERCEL_URL or defaults to http://localhost:3000

Step 3 is the trap. If you never set metadataBase and you're not on Vercel, Next.js silently defaults to localhost. There's no warning in the console, no build error, nothing. Your site just doesn't get indexed.

The fix: always set metadataBase explicitly in your root layout, and always make the fallback your production domain.

Check Your Site Right Now

Don't wait until Google Search Console tells you something is wrong. Use the Canonical URL Checker to scan your production pages for localhost leaks, tag mismatches, and schema URL issues. It takes 5 seconds.

Frequently Asked Questions

Does Google penalize sites with localhost canonical URLs?

Not a penalty in the traditional sense. Google doesn't add a negative ranking signal. It simply can't crawl http://localhost:3000, so it treats your pages as duplicates of an unreachable canonical. The practical effect is identical to being de-indexed — zero impressions, zero clicks, zero organic traffic.

Fix the canonical, request re-indexing via Search Console, and pages typically start appearing within 1-2 weeks.

How do I check if my Next.js site has this bug right now?

Two ways. First, visit your production site and View Source — search for localhost in the HTML. Check <link rel="canonical", <meta property="og:url", and any <script type="application/ld+json"> blocks. Second, run grep -rn "process\.env\.SITE_URL" --include="*.ts" --include="*.tsx" in your project root.

If you see more than one result, you have scattered URL fallbacks that could break.

Should I use VERCEL_URL or NEXT_PUBLIC_SITE_URL for the canonical fallback?

Neither as a primary. VERCEL_URL returns the deployment URL (which includes preview branch URLs like my-app-git-feature-team.vercel.app), not your production domain. NEXT_PUBLIC_SITE_URL works but requires correct configuration per environment. The safest pattern is a hardcoded production domain as the ultimate fallback, with env vars for override: process.env.SITE_URL || 'https://yourdomain.com'.

The production domain is always correct for canonical tags — you never want a preview URL as your canonical.

Can metadataBase in the root layout fix all canonical issues by itself?

It fixes relative URL resolution. If your metadata uses relative paths (/blog/my-post), metadataBase resolves them correctly. But if any page constructs absolute URLs by string concatenation (${site}/blog/${slug}), those bypass metadataBase entirely. You need both: metadataBase for the framework-level default, and a centralised URL helper for any manual URL construction.

How long does Google take to re-index after fixing localhost canonicals?

In our case, about 7-10 days for the first pages to appear in Search Console's coverage report after deploying the fix and requesting re-indexing. Full site indexing (all pages showing as "Valid" in coverage) took roughly 2-3 weeks. You can speed this up by submitting an updated sitemap and using the URL Inspection tool to request indexing for priority pages.

Topics:nextjsseocanonical-urlsgoogle-search-consoledebuggingtechnical-seo

Found This Useful?

Share it with someone who might learn from my mistakes!