<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[LoveCalcs]]></title><description><![CDATA[Engineering notes from LoveCalcs, a free love and compatibility site in 11 languages. Expect practical write-ups on internationalisation, the Next.js App Router]]></description><link>https://lovecalcs.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ade2f9b7285acc9c1d97a09/ce5cdd3c-55d4-488e-9ca9-d7cd0650cda8.png</url><title>LoveCalcs</title><link>https://lovecalcs.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 09:42:14 GMT</lastBuildDate><atom:link href="https://lovecalcs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The hreflang bug that made every page on our 11-language site point at the homepage]]></title><description><![CDATA[How a layout-level alternates block made every page of an 11-language Next.js site point at the homepage, and the helper that fixed it.]]></description><link>https://lovecalcs.hashnode.dev/the-hreflang-bug-that-made-every-page-on-our-11-language-site-point-at-the-homepage</link><guid isPermaLink="true">https://lovecalcs.hashnode.dev/the-hreflang-bug-that-made-every-page-on-our-11-language-site-point-at-the-homepage</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[i18n]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[LoveCalcs]]></dc:creator><pubDate>Mon, 26 Oct 2026 01:40:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ade2f9b7285acc9c1d97a09/0097d961-8656-4c70-adf4-957c81da4ad9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://www.lovecalcs.com">LoveCalcs</a> serves every page in 11 languages. For a while, every one of those pages quietly told search engines that its language alternates were the <strong>homepage</strong>. Here is what went wrong, the small helper that fixed it, and how we check it now.</p>
<h2>Why layouts are risky for per-page metadata</h2>
<p>In the Next.js App Router, metadata from a layout flows down to every page below it unless the page sets that field itself. The override is shallow: if a page sets <code>alternates</code>, its object replaces the layout's completely. If it doesn't, it silently inherits the layout's version. That is handy for a site name, and dangerous for anything that should be different on every URL, such as a canonical.</p>
<h2>The bug, part one: alternates in the layout</h2>
<p>Our <code>[locale]</code> layout built its alternates like this:</p>
<pre><code class="language-ts">    alternates: {
      canonical: locale === 'en' ? 'https://www.lovecalcs.com/' : `https://www.lovecalcs.com/${locale}`,
      languages: Object.fromEntries(
        locales.map(loc =&gt; [loc, loc === 'en' ? 'https://www.lovecalcs.com/' : `https://www.lovecalcs.com/${loc}`])
      )
    },
</code></pre>
<p>That is correct for the homepage and wrong for everything else. The About, Contact, Terms and Privacy pages exported a static <code>metadata</code> object with a title and a description but no alternates, so they inherited this block. Their canonical pointed at the homepage, which a search engine reads as "this page is a duplicate of the homepage".</p>
<h2>The bug, part two: hand-written tags in the head</h2>
<p>The same layout also rendered hreflang links straight into its <code>&lt;head&gt;</code>:</p>
<pre><code class="language-tsx">        {/* Hreflang tags for SEO */}
        {locales.map((loc) =&gt; (
          &lt;link
            key={loc}
            rel="alternate"
            hrefLang={loc}
            href={loc === 'en' ? 'https://www.lovecalcs.com/' : `https://www.lovecalcs.com/${loc}`}
          /&gt;
        ))}
        &lt;link rel="alternate" hrefLang="x-default" href="https://www.lovecalcs.com/" /&gt;
</code></pre>
<p>JSX in a layout is not metadata, so no page can override it. Every page under the layout got these 12 homepage-pointing tags. The calculator and quiz pages, which already built correct per-page alternates in their own <code>generateMetadata</code>, ended up with two contradicting sets of hreflang in the same head.</p>
<h2>The fix: one helper, used by every route</h2>
<p>We deleted the hardcoded tags and replaced the layout's block with a single function that builds the canonical and alternates for any path:</p>
<pre><code class="language-ts">export function buildAlternates(locale: string, path: string) {
  const clean = path === '/' ? '' : path;
  const url = (loc: string) =&gt;
    loc === 'en' ? `${baseUrl}${clean || '/'}` : `${baseUrl}/${loc}${clean}`;

  const languages: Record&lt;string, string&gt; = {};
  for (const loc of locales) languages[loc] = url(loc);
  languages['x-default'] = url('en');

  return {
    canonical: url(locale),
    languages,
  };
}
</code></pre>
<p><code>path</code> is the route without the locale, with <code>''</code> for the homepage. English has no prefix, so its URLs are unprefixed and the English homepage keeps its trailing slash. Because every alternate is generated from the same <code>locales</code> array, the set is always complete, and every page links back to all of its siblings. That reciprocity is what hreflang depends on: an alternate that doesn't link back can be ignored.</p>
<p>The layout now calls <code>buildAlternates(locale, '')</code>, which is right for the homepage, and the pages that used to inherit it declare their own:</p>
<pre><code class="language-ts">export async function generateMetadata({
  params,
}: {
  params: Promise&lt;{ locale: string }&gt;;
}): Promise&lt;Metadata&gt; {
  const { locale } = await params;
  return {
    // ...
    alternates: buildAlternates(locale, '/about'),
  };
}
</code></pre>
<p>The blog index and blog posts go through the same helper. The calculator and quiz pages kept their existing per-page code, which was only missing an <code>x-default</code>, so their fix was one line each:</p>
<pre><code class="language-ts">  hreflangAlternates['x-default'] = 'https://www.lovecalcs.com/love-calculator';
</code></pre>
<p>Those pages use a relative canonical such as <code>/love-calculator</code>. That works because the root layout sets <code>metadataBase: new URL('https://www.lovecalcs.com')</code>, and Next.js resolves relative metadata URLs against it.</p>
<h2>The rules it enforces</h2>
<ol>
<li><strong>The canonical is self-referencing.</strong> <code>/ja/about</code> is canonical to itself, not to the English page. Translations are not duplicates, and pointing every translation's canonical at English asks search engines to treat them as duplicates.</li>
<li><strong>Every page lists all 11 languages plus <code>x-default</code>,</strong> so each page has 12 alternates, including itself.</li>
<li><strong><code>x-default</code> points to English,</strong> which lives at the root with no prefix (<code>/about</code>, not <code>/en/about</code>).</li>
<li><strong>Layout alternates are for the homepage only.</strong> Every other route sets its own, and nothing hreflang-related is hardcoded in a layout's JSX.</li>
</ol>
<h2>Sitemap alternates, kept in step</h2>
<p>hreflang can also be declared in a sitemap. Ours is split into one sitemap per locale with <code>generateSitemaps</code>, and every entry lists its alternates:</p>
<pre><code class="language-ts">  const localeUrl = (lang: string, route: string) =&gt;
    lang === 'en' ? `${baseUrl}${route || '/'}` : `${baseUrl}/${lang}${route}`

  const staticEntries: MetadataRoute.Sitemap = routes.map((route) =&gt; ({
    url: localeUrl(locale, route),
    // ...
    alternates: {
      languages: Object.fromEntries(
        locales.map((lang) =&gt; [lang, localeUrl(lang, route)])
      ),
    },
  }))
</code></pre>
<p>Next.js renders <code>alternates.languages</code> as <code>xhtml:link</code> entries in the XML. Notice that <code>localeUrl</code> follows exactly the same rule as <code>buildAlternates</code>. That matters, because the sitemap and the page heads must agree. An earlier commit fixed exactly this kind of drift: the English sitemap URLs had used an <code>/en/</code> prefix, and since English pages redirect to the unprefixed path, the sitemap was pointing crawlers at redirects. The sitemap entries don't include <code>x-default</code>; that one comes from the page heads.</p>
<p>The same fix commit also added <code>export const revalidate = 3600</code> to the per-locale sitemaps and to the sitemap index. The home and calculator pages carry content that rotates daily, so their <code>lastmod</code> is today's UTC date, and without revalidation it would have been frozen at build time. Every other route reports a fixed date for its last real change, rather than claiming "modified now" on every crawl.</p>
<h2>Common mistakes worth checking for</h2>
<ul>
<li><strong>hreflang or canonicals set in a shared layout,</strong> which is the bug above.</li>
<li><strong>Canonicals that point every translation at the default language.</strong></li>
<li><strong>Mixed URL forms.</strong> <code>/en/about</code> against <code>/about</code>, or the bare domain against <code>www</code>. Production redirects the bare domain to <code>www</code>, and an earlier commit moved every hardcoded URL to <code>www</code> to avoid redirect chains. Every alternate should return a 200 directly.</li>
<li><strong>A missing <code>x-default</code>.</strong></li>
<li><strong>Partial sets,</strong> where a page lists some languages but not all of them, so the return links don't match.</li>
</ul>
<h2>How to audit it</h2>
<p>The quickest test is to fetch a deep page in a non-default language with <code>curl</code> and grep the HTML for <code>rel="canonical"</code> and <code>rel="alternate"</code>. Try it on our <a href="https://www.lovecalcs.com/ja/about">About page in Japanese</a>: you should see one canonical pointing at that exact URL, and 12 alternates pointing at the About page in each language plus <code>x-default</code>. If the homepage shows up on a page that isn't the homepage, you have this bug.</p>
<p>Then go one step further:</p>
<ul>
<li>Request each alternate URL and confirm it returns 200 without a redirect.</li>
<li>Open one of the alternates and check that it lists the same 12 URLs.</li>
<li>Compare the head tags with the <code>xhtml:link</code> entries in that locale's sitemap.</li>
<li>In your search engine's webmaster tools, compare the canonical you declared with the one the search engine chose.</li>
</ul>
<h2>Takeaway</h2>
<p>hreflang bugs are silent. Nothing breaks for users, so nobody notices. If your site is multilingual and uses nested layouts, check what each page <strong>inherits</strong>, not just what it declares.</p>
<p>The site in question is a free <a href="https://www.lovecalcs.com/love-calculator">love calculator and compatibility test in 11 languages</a>, if you want to see the tags for yourself.</p>
]]></content:encoded></item></channel></rss>