DEV Community

Cover image for Why Next.js App Router Silently Broke Our Open Graph & SEO Topology (And How We Fixed It)
Chen Tao
Chen Tao

Posted on

Why Next.js App Router Silently Broke Our Open Graph & SEO Topology (And How We Fixed It)

Last week, while preparing our production gaming companion — Dungeon Quest Reborn Guide — for launch using Next.js 16 (output: 'export'), Ahrefs and Google Search Console flagged two critical, silent failures:

  1. Every single inner page had an og:url pointing to our homepage root (/).
  2. Nine newly generated dynamic detail pages had 0 internal inlinks and were classified as "Absolute Orphans," despite being submitted in sitemap.xml.

Nothing threw an error during next build. TypeScript passed. ESLint was green. The site exported cleanly to HTML.

Yet underneath, our social metadata was broken, our Title tags were overflowing 90+ characters, and crawlers had no physical link path to crawl our dynamic detail pages.

Here is a breakdown of why this happens in Next.js App Router, how the framework's metadata inheritance silently bites developers, and the automated Node.js postbuild gate we wrote to make sure these bugs never reach production again.


1. The Fatal Open Graph Fallback Trap

If you're using Next.js 13, 14, 15, or 16 with App Router, you probably defined global metadata inside your root src/app/layout.tsx:

// src/lib/layout.tsx or src/app/layout.tsx
export const metadata: Metadata = {
  title: "{"
    default: 'Site Title',
    template: '%s | Site Brand',
  },
  openGraph: {
    title: "'Site Title',"
    description: "'Site Description',"
    url: 'https://mysite.com/',
    siteName: 'Site Brand',
    images: [{ url: 'https://mysite.com/og.jpg', width: 1200, height: 630 }],
    type: 'website',
  },
};
Enter fullscreen mode Exit fullscreen mode

Then, in a child route like src/app/pricing/page.tsx or src/app/dungeons/[slug]/page.tsx, you write a quick metadata export:

// ❌ THE SILENT TRAP
export const metadata: Metadata = {
  title: "'Pricing & Plans',"
  description: "'Affordable plans for everyone.',"
};
Enter fullscreen mode Exit fullscreen mode

What Next.js actually outputs:

Because you did not explicitly redeclare the openGraph object in the child page, Next.js performs shallow inheritance.

It takes the openGraph object from your root layout and merges it. As a result, your /pricing/ HTML ends up with:

<link rel="canonical" href="https://mysite.com/pricing/" />
<!-- BUT OPEN GRAPH SAYS: -->
<meta property="og:url" content="https://mysite.com/" />
<meta property="og:title" content="Site Title" />
Enter fullscreen mode Exit fullscreen mode

When someone shares https://mysite.com/pricing/ on Twitter, LinkedIn, or Discord, the platform's preview bot parses og:url: https://mysite.com/ and displays your homepage title, description, and thumbnail instead of the pricing page! Furthermore, Ahrefs and Moz flag this as a critical canonical mismatch.

The Fix: Unified Metadata Helper

Never write raw export const metadata = { ... } in child routes. Instead, route all page metadata through a strict helper function:

// src/lib/seo.ts
import type { Metadata } from 'next';

const BASE_URL = 'https://mysite.com';
const BRAND_NAME = 'MyBrand';

interface PageSEOProps {
  title: "string;"
  description: "string;"
  path: string; // e.g. '/pricing/'
  ogImage?: string;
}

export function generateSEOMetadata({
  title,
  description,
  path,
  ogImage = '/images/og-card.jpg',
}: PageSEOProps): Metadata {
  const cleanPath = path.startsWith('/') ? path : `/${path}`;
  const canonicalUrl = `${BASE_URL}${cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`}`;
  const fullOgImageUrl = ogImage.startsWith('http') ? ogImage : `${BASE_URL}${ogImage}`;

  return {
    title,
    description,
    alternates: {
      canonical: canonicalUrl,
    },
    openGraph: {
      title,
      description,
      url: canonicalUrl, // 🛡️ Explicitly locked to the actual page URL
      siteName: BRAND_NAME,
      images: [
        {
          url: fullOgImageUrl,
          width: 1200,
          height: 630,
          alt: `${title} - ${BRAND_NAME}`,
        },
      ],
      type: 'website',
    },
    twitter: {
      card: 'summary_large_image',
      title,
      description,
      images: [fullOgImageUrl],
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Now in any child page:

// ✅ SAFE & IMMUTABLE
export const metadata = generateSEOMetadata({
  title: "'Pricing & Plans',"
  description: "'Explore tiered pricing options and feature breakdowns for teams of all sizes.',"
  path: '/pricing/',
});
Enter fullscreen mode Exit fullscreen mode

2. The Double-Brand Title Inflation (90+ Char Truncation)

In your root layout, you likely have this template setup:

title: {
  default: 'Dungeon Quest Guide',
  template: '%s | Dungeon Quest',
}
Enter fullscreen mode Exit fullscreen mode

When building individual guide pages, developers naturally write descriptive titles:

export const metadata = {
  title: 'Dungeon Quest Desert Temple Boss Guide',
};
Enter fullscreen mode Exit fullscreen mode

When Next.js compiles the page, it plugs the child title into %s:

Dungeon Quest Desert Temple Boss Guide | Dungeon Quest

Result: 54 characters + 16 characters = 70 characters. If the page name was slightly longer, it easily ballooned to 85–96 characters.

Google truncates titles at approximately 580–600 pixels (roughly 55–60 characters). The brand name at the end was completely cut off, replaced by ..., while the duplicate brand keyword at the start made the snippet look like automated spam.

The Budget Rule:

  • Child Page Prefix: Keep it between 25 and 38 characters (e.g. Desert Temple Boss Guide).
  • Layout Suffix: Let %s | Brand append the brand.
  • Total Rendered Title: Lands squarely in the 45 to 60 character sweet spot.

3. The Dynamic SSG Orphan Page Crisis

Static Site Generation (output: 'export') with generateStaticParams() is one of Next.js's superpowers. You feed it an array of slugs, and it compiles pristine static HTML files.

// src/app/bosses/[slug]/page.tsx
export async function generateStaticParams() {
  const bosses = getBossData();
  return bosses.map((b) => ({ slug: b.slug }));
}
Enter fullscreen mode Exit fullscreen mode

When you inspect out/sitemap.xml, every /bosses/desert-temple/, /bosses/winter-outpost/, etc., is proudly listed.

Here is where the problem hits:
Many developers build their parent hub page (/bosses/page.tsx) with client-side interactive tabs, filter chips, or search boxes using React state:

// ❌ Crawlers without JS see empty space
'use client';
export default function BossHub() {
  const [selectedTier, setSelectedTier] = useState('Tier1');
  // Sub-pages rendered only after user clicks a filter...
}
Enter fullscreen mode Exit fullscreen mode

Search engine crawlers (and audit bots like Ahrefs/Screaming Frog) parse the initial static HTML response. If the parent page doesn't output literal <a href="/bosses/slug/"> tags directly in the server-rendered DOM, those dynamic subpages have 0 inbound internal links.

They become Absolute Orphans. Even if they are in your sitemap, Google prioritizes pages with internal authority flow. Without inbound links, orphan pages rarely rank.

The Fix:

  1. Server-Render Literal Grids: Even if you have client-side filtering, ensure the initial HTML renders an accessible <nav> or grid of standard Next.js <Link href="..."> cards for every entity. For example, on our Dungeon Quest Boss Directory, we render a literal card grid of all 9 dungeon encounters directly in the initial HTML so web crawlers can traverse into each individual encounter without executing a single line of client-side JavaScript.
  2. Bidirectional Linking: If /dungeons/desert-temple/ references a boss, link directly to /bosses/desert-temple/. On /bosses/desert-temple/, provide a direct return link to /dungeons/desert-temple/. This guarantees every leaf page has $\ge 2$ strong inlinks.

4. Shift-Left SEO: The Automated Postbuild Audit Script

We got tired of discovering these issues post-deployment. So we built an automated, zero-dependency Node.js verification script that runs in postbuild.

If any page violates title length, description budgets, or Open Graph integrity, the build fails and blocks the release.

Save this as scripts/audit-seo-meta.mjs:

import fs from 'node:fs';
import path from 'node:path';

const OUT_DIR = path.join(process.cwd(), 'out');

if (!fs.existsSync(OUT_DIR)) {
  console.error('❌ out/ directory not found. Run next build first.');
  process.exit(1);
}

function scanHtmlFiles(dir) {
  let files = [];
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      files = files.concat(scanHtmlFiles(full));
    } else if (entry.name.endsWith('.html') && !entry.name.startsWith('404')) {
      files.push(full);
    }
  }
  return files;
}

const htmlFiles = scanHtmlFiles(OUT_DIR);
let errors = [];

for (const file of htmlFiles) {
  const html = fs.readFileSync(file, 'utf8');
  const relPath = path.relative(OUT_DIR, file).replace(/\\/g, '/');
  const route = '/' + relPath.replace(/\/index\.html$/, '/').replace(/\.html$/, '');

  if (route.includes('_not-found')) continue;

  // 1. Audit Title Length & Duplicate Brand Suffixes
  const titleMatch = html.match(/<title>([^<]+)<\/title>/);
  if (!titleMatch) {
    errors.push(`[${route}] Missing <title> tag.`);
  } else {
    const title = titleMatch[1];
    if (title.length < 35 || title.length > 65) {
      errors.push(`[${route}] Title length (${title.length}) outside safe window (35-65): "${title}"`);
    }
    // Check for double brand repeat (e.g. "Brand ... | Brand")
    const brandOccurrences = (title.match(/MyBrand/g) || []).length;
    if (brandOccurrences > 1) {
      errors.push(`[${route}] Duplicate brand name detected in title: "${title}"`);
    }
  }

  // 2. Audit Meta Description (120-158 char sweet spot)
  const descMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
  if (!descMatch) {
    errors.push(`[${route}] Missing meta description.`);
  } else {
    const descLen = descMatch[1].length;
    if (descLen < 110 || descLen > 160) {
      errors.push(`[${route}] Description length (${descLen}) out of range (110-160): "${descMatch[1]}"`);
    }
  }

  // 3. Audit Open Graph URL Parity
  const ogUrlMatch = html.match(/<meta\s+property=["']og:url["']\s+content=["']([^"']+)["']/i);
  const canonicalMatch = html.match(/<link\s+rel=["']canonical["']\s+href=["']([^"']+)["']/i);

  if (!ogUrlMatch) {
    errors.push(`[${route}] Missing og:url tag.`);
  } else if (!canonicalMatch) {
    errors.push(`[${route}] Missing canonical link tag.`);
  } else if (ogUrlMatch[1] !== canonicalMatch[1]) {
    errors.push(`[${route}] og:url mismatch! og:url="${ogUrlMatch[1]}" vs canonical="${canonicalMatch[1]}"`);
  }
}

if (errors.length > 0) {
  console.error('\n🚨 SEO METADATA AUDIT FAILED:\n');
  errors.forEach((err) => console.error(` - ${err}`));
  process.exit(1);
} else {
  console.log(`\n✅ SEO METADATA AUDIT PASSED: ${htmlFiles.length} pages verified.\n`);
}
Enter fullscreen mode Exit fullscreen mode

Add it directly to your package.json:

{
  "scripts": {
    "build": "next build",
    "postbuild": "node scripts/audit-seo-meta.mjs"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, if a developer introduces an unescaped double-title, leaves out an Open Graph tag, or writes a 200-character description that gets truncated on mobile SERPs, the build halts immediately before anything reaches production.


Summary Checklist

  1. Beware of Next.js openGraph shallow inheritance: If a child route doesn't redeclare openGraph.url, it defaults to the root layout's URL.
  2. Watch your Title budget: If using %s | Brand, child titles should be strictly 25–38 chars to prevent SERP truncation.
  3. SSG + sitemap $\neq$ healthy crawl topology: Always server-render literal <a> links from parent hubs to prevent orphan subpages.
  4. Automate in postbuild: Don't wait for Ahrefs or Google Search Console to email you a week after launch. Inspect the output HTML as part of your CI pipeline.

Top comments (0)