Put a canonical URL in an App Router layout and it goes out on every page of the site. Nothing errors, nothing warns, and you don't find out until you open the built HTML.
grep -o '<link rel="canonical" href="[^"]*"' out/about/index.html
<link rel="canonical" href="https://eliseapps.com/"
The about page is announcing itself as the home page. So are /blog/ and /support/. To a crawler, nearly every page on the site is a duplicate of one other page.
The layout's metadata did it
Here is the culprit:
// app/layout.tsx export const metadata: Metadata = { title: { template: '%s | Yuki Tech Portfolio', default: 'Yuki Tech Portfolio' }, metadataBase: new URL('https://eliseapps.com'), alternates: { canonical: '/', languages: { 'ja-JP': '/', 'en-US': '/en' }, }, };
I wrote that intending to set the canonical for the home page. What actually happens is that App Router metadata is inherited from layout to page field by field. A page that doesn't declare alternates gets the parent's value verbatim. It is the same mechanism that lets title.template compose downward, applied to a field where inheriting is exactly wrong.
The unpleasant part is that nothing complains. The build succeeds, the pages render identically, and there is no warning anywhere. You find out by reading out/.
The fix
Drop alternates from the layout. A canonical URL is per-page by definition, so it never belonged there.
export const metadata: Metadata = { title: { template: '%s | Yuki Tech Portfolio', default: 'Yuki Tech Portfolio' }, metadataBase: new URL('https://eliseapps.com'), // No alternates here — it would land on every child page. };
Then have each page declare its own. Copying the same shape into every route file gets old fast, so I put it behind a helper:
// seo.ts export const SITE_URL = 'https://eliseapps.com'; export const ROUTES = { home: '/', about: '/about/', blog: '/blog/', software: '/software/', } as const; export async function pageMetadata(lang: 'ja' | 'en', route: keyof typeof ROUTES) { const path = ROUTES[route]; const jaUrl = `${SITE_URL}${path}`; const enUrl = `${SITE_URL}/en${path}`; return { alternates: { canonical: lang === 'ja' ? jaUrl : enUrl, languages: { 'ja-JP': jaUrl, 'en-US': enUrl, 'x-default': jaUrl }, }, // title, description and openGraph get assembled here too }; }
Pages just call it:
// app/about/page.tsx export async function generateMetadata(): Promise<Metadata> { return pageMetadata('ja', 'about'); }
Keeping the route table in one place means sitemap.ts can read the same object, which killed the hand-maintained list of routes I had been forgetting to update. That might have been the bigger win.
Bilingual sites have a second trap
Japanese lives at the root of this site and English under /en, so there are two layouts:
app/(ja)/layout.tsx → Japanese app/en/layout.tsx → English
The English one looked like this:
import BaseLayout from '@/components/BaseLayout'; export default async function EnLayout({ children }) { return <BaseLayout lang="en">{children}</BaseLayout>; }
It renders the shared layout component, so it looks complete. But metadata is collected from a file's exports, not from whatever its component renders. Defining metadata inside BaseLayout does not make it an export of app/en/layout.tsx.
With this shape, every page under /en renders without a <title>. The Japanese layout carries one extra line, and that line is the entire difference:
import BaseLayout from '@/components/BaseLayout'; export { metadata } from '@/components/BaseLayout'; // this is the load-bearing line
Check the output, because nothing else will
Broken metadata compiles. The only way to catch it locally is to look at the HTML, and the fastest way to look is to look at all of it at once.
find out -name index.html | while read f; do printf '%-40s %s\n' "${f#out/}" \ "$(grep -o '<link rel="canonical" href="[^"]*"' "$f" || echo 'MISSING')" done
Every canonical should be distinct and nothing should say MISSING. This runs before I deploy now.
The sitemap has a matching trap: with trailingSlash: true on, sitemap URLs written without the slash turn every entry into a 308 redirect. That one is written up separately.