Initializing Enclave...

How to Fix Next.js App Router Metadata Export Not Working in Server Components

Threat/Impact Level: HIGH | Downtime Risk: HIGH (SEO/social previews fully broken in production) | Time to Fix: 5–15 mins


TL;DR

  • What broke: export const metadata is either in a 'use client' component or exported from a file that isn't a page.tsx/layout.tsx, so Next.js never picks it up — your <title> and <meta description> are blank in production.
  • How to fix it: Move all metadata exports exclusively into Server Component page.tsx or layout.tsx files. For dynamic values, replace with export async function generateMetadata().
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your broken component and get corrected code without sending your routes or API keys to a third-party server.

The Incident (What Does the Error Mean?)

There is no thrown exception. That's what makes this brutal. Your build succeeds. Your app runs. But in production:

<!-- Rendered <head> in production -->
<title></title>
<meta name="description" content="" />

Googlebots, Twitter cards, and Open Graph scrapers all receive empty metadata. Your SEO equity craters silently. The Next.js App Router only processes metadata exports from Server Components that are either page.tsx or layout.tsx. Any other location is a no-op.

Common triggers:

  1. 'use client' directive at the top of the file containing export const metadata
  2. Exporting metadata from a sub-component (e.g., components/Hero.tsx) instead of the route segment file
  3. Mixing static metadata export with generateMetadata() in the same file (last one wins, unpredictably)
  4. Using metadata in a Server Component that is not a route segment (page or layout)

The Attack Vector / Blast Radius

This isn't a security exploit — it's a slow-burn SEO and revenue incident:

  • Google re-crawl lag: Once Googlebot indexes blank titles, recovery takes 2–6 weeks of re-crawl cycles even after you fix the code.
  • Social sharing is dead: Every Slack/Twitter/LinkedIn unfurl shows no title, no description, no OG image. Click-through rates collapse.
  • Paid campaign waste: If you're running Google Ads or Meta Ads with dynamic landing pages, blank metadata invalidates Quality Scores and raises CPCs.
  • Next.js gives you zero runtime warning. The metadata object is simply ignored. No console error, no build warning, no type error unless you're on strict TypeScript with Metadata type imported from next.

How to Fix It

Basic Fix — Static Metadata

Remove 'use client' from the file, or move metadata to the correct page.tsx:

- 'use client';
- import { Metadata } from 'next';
-
- export const metadata: Metadata = {
-   title: 'My Page',
-   description: 'This is my page',
- };
-
- export default function MyPage() {
-   return <div>Content</div>;
- }

+ // page.tsx — NO 'use client' directive
+ import { Metadata } from 'next';
+
+ export const metadata: Metadata = {
+   title: 'My Page',
+   description: 'This is my page',
+ };
+
+ export default function MyPage() {
+   return <div>Content</div>;
+ }

If you need client interactivity on this page, split the component:

- // page.tsx
- 'use client';
- export const metadata = { title: 'Dashboard' }; // IGNORED — 'use client' kills this
- export default function Dashboard() { ... }

+ // page.tsx (Server Component — no 'use client')
+ import { Metadata } from 'next';
+ import DashboardClient from './DashboardClient'; // client logic isolated here
+
+ export const metadata: Metadata = {
+   title: 'Dashboard',
+   description: 'Your personal dashboard',
+ };
+
+ export default function DashboardPage() {
+   return <DashboardClient />;
+ }

Enterprise Best Practice — Dynamic Metadata with generateMetadata()

For route params, database-driven titles, or per-tenant SEO:

- export const metadata = {
-   title: 'Product', // static, ignores slug
- };

+ import { Metadata, ResolvingMetadata } from 'next';
+
+ type Props = {
+   params: { slug: string };
+ };
+
+ export async function generateMetadata(
+   { params }: Props,
+   parent: ResolvingMetadata
+ ): Promise<Metadata> {
+   const product = await fetchProduct(params.slug); // your data fetch
+   const parentOG = (await parent).openGraph?.images || [];
+
+   return {
+     title: product.name,
+     description: product.description,
+     openGraph: {
+       images: [product.imageUrl, ...parentOG],
+     },
+   };
+ }

Critical rules for generateMetadata:

  • Must be in page.tsx or layout.tsx only
  • Cannot coexist with export const metadata in the same file
  • Next.js deduplicates fetch() calls between generateMetadata and the page component — use the same cache keys

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

1. ESLint — enforce metadata location at lint time

Install eslint-plugin-next (ships with create-next-app) and ensure this rule is active:

// .eslintrc.json
{
  "extends": ["next/core-web-vitals"],
  "rules": {
    "@next/next/no-head-element": "error"
  }
}

Add a custom ESLint rule or use grep in your pipeline to catch 'use client' + metadata coexistence:

# In your CI pipeline (GitHub Actions, GitLab CI)
- name: Detect broken metadata exports
  run: |
    if grep -rl "use client" ./app --include="*.tsx" | xargs grep -l "export const metadata" 2>/dev/null; then
      echo "ERROR: 'use client' and metadata export found in same file"
      exit 1
    fi

2. TypeScript strict typing — catch missing Metadata type

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "plugins": [{ "name": "next" }]
  }
}

Typing metadata as Metadata from next will surface type mismatches at build time before deployment.

3. Playwright/Puppeteer smoke test in staging

// tests/metadata.spec.ts
test('homepage has valid title and description', async ({ page }) => {
  await page.goto(process.env.STAGING_URL!);
  const title = await page.title();
  const description = await page.$eval(
    'meta[name="description"]',
    (el) => el.getAttribute('content')
  );
  expect(title).not.toBe('');
  expect(description).not.toBe('');
  expect(description!.length).toBeGreaterThan(10);
});

Run this in your CD pipeline before promoting to production. A blank title is a hard failure.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →