<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabi Florea</title>
    <description>The latest articles on DEV Community by Gabi Florea (@gabelul).</description>
    <link>https://dev.to/gabelul</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3634751%2F8faf6ad0-4282-4aa8-93bc-1e04c1d89038.png</url>
      <title>DEV Community: Gabi Florea</title>
      <link>https://dev.to/gabelul</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabelul"/>
    <language>en</language>
    <item>
      <title>How I Fixed Canonical URLs Pointing to Localhost in Next.js</title>
      <dc:creator>Gabi Florea</dc:creator>
      <pubDate>Fri, 05 Jun 2026 11:36:10 +0000</pubDate>
      <link>https://dev.to/gabelul/how-i-fixed-canonical-urls-pointing-to-localhost-in-nextjs-2g6p</link>
      <guid>https://dev.to/gabelul/how-i-fixed-canonical-urls-pointing-to-localhost-in-nextjs-2g6p</guid>
      <description>&lt;p&gt;Next.js sites using &lt;code&gt;process.env.SITE_URL || 'http://localhost:3000'&lt;/code&gt; as a canonical URL fallback will fail Google indexing entirely. The fix: one centralised &lt;code&gt;getPublicSiteUrl()&lt;/code&gt; helper replacing all scattered inline fallbacks. 13 files, 18 lines of code, zero pages indexed before, full indexing restored after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one where I made myself invisible to Google
&lt;/h2&gt;

&lt;p&gt;So here's the scene. I've been doing SEO for &lt;strong&gt;over a decade&lt;/strong&gt;. 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 &lt;em&gt;begin&lt;/em&gt; to understand.&lt;/p&gt;

&lt;p&gt;This is literally what I do.&lt;/p&gt;

&lt;p&gt;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 &lt;del&gt;perfect&lt;/del&gt; &lt;del&gt;good enough&lt;/del&gt; airtight. Deployed it, submitted to &lt;a href="https://support.google.com/webmasters/answer/7440203" rel="noopener noreferrer"&gt;Search Console&lt;/a&gt;, cracked open a beer, and waited for Google to do its thing.&lt;/p&gt;

&lt;p&gt;Two weeks go by. Nothing indexed. &lt;em&gt;Okay, new domain, Google's slow sometimes.&lt;/em&gt; Three weeks.&lt;/p&gt;

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

&lt;p&gt;Every. Single. One.&lt;/p&gt;

&lt;p&gt;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 &lt;del&gt;panicking&lt;/del&gt; rational SEO professional would do: I &lt;a href="https://booplex.com/blog/a-regex-matched-pre-as-p-and-made-half-my-blog-invisible" rel="noopener noreferrer"&gt;View Source on my own production site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And there it is. Right there in the HTML. Mocking me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:3000/projects/runnerkit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Localhost.&lt;/em&gt; On &lt;em&gt;production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My canonical URLs were telling Google to go index my living room laptop. Every single page on my site — the one built by &lt;del&gt;a professional SEO consultant&lt;/del&gt; &lt;del&gt;someone who should know better&lt;/del&gt; 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 &lt;strong&gt;five weeks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I genuinely considered &lt;del&gt;never speaking of this again&lt;/del&gt; 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 &lt;strong&gt;one line of code&lt;/strong&gt; that works perfectly in development and silently destroys everything in production.&lt;/p&gt;

&lt;p&gt;So here we are. My canonical shame, your shortcut to the fix.&lt;/p&gt;

&lt;p&gt;The short version: every &lt;a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls" rel="noopener noreferrer"&gt;canonical URL&lt;/a&gt;, every &lt;code&gt;og:url&lt;/code&gt;, and every sitemap entry pointed to &lt;code&gt;http://localhost:3000&lt;/code&gt; in production. Thirteen files. One copy-pasted line of code. Five weeks of zero organic visibility.&lt;/p&gt;

&lt;p&gt;And an 18-line fix that brought everything back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Diagnosis Checklist
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What to Check&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Canonical tag in production HTML&lt;/td&gt;
&lt;td&gt;View Source → search for &lt;code&gt;&amp;lt;link rel="canonical"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;og:url&lt;/code&gt; meta tag&lt;/td&gt;
&lt;td&gt;View Source → search for &lt;code&gt;og:url&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sitemap URLs&lt;/td&gt;
&lt;td&gt;Open &lt;code&gt;/sitemap.xml&lt;/code&gt; in browser&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google's selected canonical&lt;/td&gt;
&lt;td&gt;Search Console → URL Inspection → "Google-selected canonical"&lt;/td&gt;
&lt;td&gt;GSC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema &lt;code&gt;@id&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt; fields&lt;/td&gt;
&lt;td&gt;View Source → search for &lt;code&gt;application/ld+json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;metadataBase&lt;/code&gt; resolution&lt;/td&gt;
&lt;td&gt;Check root &lt;code&gt;layout.tsx&lt;/code&gt; → &lt;code&gt;generateMetadata()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If any of these show &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, or a port number, your site is invisible to Google. Full stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does Google Do When a Canonical URL Points to Localhost?
&lt;/h2&gt;

&lt;p&gt;Google treats &lt;code&gt;http://localhost:3000&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The result in Search Console:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Status:&lt;/strong&gt; "Duplicate, Google chose different canonical than user"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or:&lt;/strong&gt; "Discovered - currently not indexed"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-declared canonical:&lt;/strong&gt; &lt;code&gt;http://localhost:3000/projects/my-project&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google-selected canonical:&lt;/strong&gt; None (can't reach it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Did 13 Files End Up With the Same Bug?
&lt;/h2&gt;

&lt;p&gt;The pattern looked like this, scattered across the codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This was in 13 different files&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h3&gt;
  
  
  Files affected
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it broke&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/layout.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;metadataBase&lt;/code&gt; for all pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/blog/[slug]/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blog post canonical + &lt;code&gt;og:url&lt;/code&gt; + share URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/blog/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blog listing canonical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/projects/[slug]/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Project page canonical + schema &lt;code&gt;url&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/projects/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Projects listing canonical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/case-studies/[slug]/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Case study canonical + schema &lt;code&gt;url&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/case-studies/page.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Case studies canonical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;components/seo/PageSEOSchemas.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON-LD &lt;code&gt;@id&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt; in all schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/.well-known/llms.txt/route.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llms.txt&lt;/code&gt; redirect target&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;h2&gt;
  
  
  Data Experiment: Auditing a Codebase for Scattered URL Patterns
&lt;/h2&gt;

&lt;p&gt;I ran a grep across the full codebase to count how many files independently constructed the site URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"process&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;env&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;SITE_URL"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.ts"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.tsx"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; node_modules | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;".next"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;13 matches across 9 files.&lt;/strong&gt; Every single one had a &lt;code&gt;localhost&lt;/code&gt; fallback. None of them used the database-driven URL.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;localhost&lt;/code&gt; is correct during development — the bug only manifests in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; If you grep your Next.js project for &lt;code&gt;process.env.SITE_URL&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does the 18-Line Fix Actually Look Like?
&lt;/h2&gt;

&lt;p&gt;One helper function. One source of truth. Replace all 13 scattered patterns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/utils.ts&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * 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.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPublicSiteUrl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://booplex.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Then every file gets the same one-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const site = (process.env.SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
&lt;/span&gt;&lt;span class="gi"&gt;+ import { getPublicSiteUrl } from '@/lib/utils';
+ const site = getPublicSiteUrl();
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Does metadataBase Matter More Than Individual Canonicals?
&lt;/h2&gt;

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

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

&lt;p&gt;Both layers need the fix. &lt;code&gt;metadataBase&lt;/code&gt; handles relative URLs. &lt;code&gt;getPublicSiteUrl()&lt;/code&gt; handles absolute URL construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Is This Fix the Wrong Approach?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If your production URL changes per deployment&lt;/strong&gt; (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 &lt;code&gt;VERCEL_URL&lt;/code&gt; or equivalent — use that as a secondary fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPublicSiteUrl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_URL&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERCEL_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-production-domain.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  How Do You Stop This Bug From Coming Back?
&lt;/h2&gt;

&lt;p&gt;Three things I did after the fix:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Added a rule to the project's CLAUDE.md&lt;/strong&gt; (the AI coding assistant's config file):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEVER use process.env.SITE_URL || '...' inline.
Always import and use getPublicSiteUrl() from @/lib/utils.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches it at the AI-assisted coding layer. Every new page template generated by Claude Code uses the helper automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grep check in CI:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fails the build if any file has an inline SITE_URL fallback&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"process&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;env&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;SITE_URL.*||"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.ts"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.tsx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"lib/utils.ts"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; node_modules&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: Use getPublicSiteUrl() instead of inline SITE_URL fallback"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  How Long Did Full Google Re-Indexing Take?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~Feb 2026&lt;/td&gt;
&lt;td&gt;Site launched with 13 inline &lt;code&gt;SITE_URL&lt;/code&gt; fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 15&lt;/td&gt;
&lt;td&gt;Noticed zero indexed pages in Google Search Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 23&lt;/td&gt;
&lt;td&gt;Identified localhost in canonical tags via View Source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 23&lt;/td&gt;
&lt;td&gt;Committed fix: &lt;code&gt;getPublicSiteUrl()&lt;/code&gt; replacing all 13 occurrences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 23&lt;/td&gt;
&lt;td&gt;Deployed, verified production HTML shows correct domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~Apr 1&lt;/td&gt;
&lt;td&gt;Google began indexing pages (Search Console showed coverage improvement)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does Next.js 15 generateMetadata Handle URL Resolution?
&lt;/h2&gt;

&lt;p&gt;Next.js resolves &lt;a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata" rel="noopener noreferrer"&gt;metadata URLs&lt;/a&gt; in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Absolute URL in metadata&lt;/strong&gt; → used as-is (this is where the bug hit)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relative URL in metadata&lt;/strong&gt; → resolved against &lt;code&gt;metadataBase&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metadataBase&lt;/code&gt; not set&lt;/strong&gt; → Next.js tries to infer from &lt;code&gt;VERCEL_URL&lt;/code&gt; or defaults to &lt;code&gt;http://localhost:3000&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the trap. If you never set &lt;code&gt;metadataBase&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix: always set &lt;code&gt;metadataBase&lt;/code&gt; explicitly in your root layout, and always make the fallback your production domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check Your Site Right Now
&lt;/h2&gt;

&lt;p&gt;Don't wait until Google Search Console tells you something is wrong. Use the &lt;a href="https://booplex.com/tools/canonical-checker" rel="noopener noreferrer"&gt;Canonical URL Checker&lt;/a&gt; to scan your production pages for localhost leaks, tag mismatches, and schema URL issues. It takes 5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does Google penalize sites with localhost canonical URLs?
&lt;/h3&gt;

&lt;p&gt;Not a penalty in the traditional sense. Google doesn't add a negative ranking signal. It simply can't crawl &lt;code&gt;http://localhost:3000&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Fix the canonical, request re-indexing via Search Console, and pages typically start appearing within 1-2 weeks.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check if my Next.js site has this bug right now?
&lt;/h3&gt;

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

&lt;p&gt;If you see more than one result, you have scattered URL fallbacks that could break.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use VERCEL_URL or NEXT_PUBLIC_SITE_URL for the canonical fallback?
&lt;/h3&gt;

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

&lt;p&gt;The production domain is always correct for canonical tags — you never want a preview URL as your canonical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can metadataBase in the root layout fix all canonical issues by itself?
&lt;/h3&gt;

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

&lt;h3&gt;
  
  
  How long does Google take to re-index after fixing localhost canonicals?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
