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 metadatais either in a'use client'component or exported from a file that isn't apage.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
metadataexports exclusively into Server Componentpage.tsxorlayout.tsxfiles. For dynamic values, replace withexport 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:
'use client'directive at the top of the file containingexport const metadata- Exporting
metadatafrom a sub-component (e.g.,components/Hero.tsx) instead of the route segment file - Mixing static
metadataexport withgenerateMetadata()in the same file (last one wins, unpredictably) - Using
metadatain a Server Component that is not a route segment (pageorlayout)
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
metadataobject is simply ignored. No console error, no build warning, no type error unless you're on strict TypeScript withMetadatatype imported fromnext.
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.tsxorlayout.tsxonly - Cannot coexist with
export const metadatain the same file - Next.js deduplicates
fetch()calls betweengenerateMetadataand 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.