July 12, 2026 · Varun Sharma
Next.js Dynamic Metadata: How to Fix Wrong Titles and OG Tags on Dynamic Routes
If you've ever visited a dynamic route on a Next.js site — a blog post, a product page, a user profile — and seen the title, description, or Open Graph tags from a different page entirely, you've hit one of the more deceptively common bugs in the App Router: stale or incorrectly resolved dynamic metadata. It's not always obvious you have this bug, because the page's visible content might load correctly while only the <head> metadata is wrong — or in worse cases, the entire page falls back to the wrong route's content.
This matters more than it might seem. Search engines index the metadata and content together. If Google crawls /blog/post-b and sees the title, canonical URL, and Open Graph tags for /blog/post-a, at best that page gets indexed with confusing, wrong signals. At worst, it gets treated as a near-duplicate and effectively drops out of meaningful ranking contention.
Why This Happens
There are a few common root causes, roughly in order of how often I see them in the wild:
1. generateMetadata Not Actually Receiving the Right Params
In the App Router, dynamic metadata is generated with an async generateMetadata function that receives route params:
tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
url: `https://example.com/blog/${post.slug}`,
},
};
}If getPostBySlug has a bug — say, it falls back to a default post when the slug lookup fails silently, instead of throwing or returning notFound() — you'll get exactly this symptom: a valid-looking page with someone else's metadata.
Fix: never let your slug-lookup function fail silently. If no post matches, call notFound() explicitly rather than returning a default or the first item in a list.
tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
return { title: post.title, description: post.description };
}2. Static Params Mismatch with generateStaticParams
If you're statically generating blog post pages, generateStaticParams needs to return every valid slug — and it needs to match exactly what your data source considers valid.
tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}A subtle bug here: if your CMS or data source updates a slug but your build was cached before the update, Next.js may serve a stale statically-generated page for the old slug — including old metadata — while your data layer thinks the new slug is canonical. This is a caching/ISR issue more than a code bug, and it's the next section.
3. ISR Caching Serving a Stale Build
Incremental Static Regeneration is powerful, but it means a page can be served from a cached build that's out of sync with your current data. If you added a new blog post and its slug happens to route through a dynamic segment that was previously matched to a different post (or a fallback), you can end up serving old, cached, wrong content until revalidation kicks in.
Fix: set an explicit and appropriate revalidate value, and for content that changes rarely but needs to be correct immediately (like a newly published post), trigger on-demand revalidation via revalidatePath() or revalidateTag() right when you publish, rather than waiting for a time-based revalidation window.
tsx
import { revalidatePath } from 'next/cache';
// after publishing a new post
revalidatePath("/blog/${newSlug}`);4. Fallback Behavior Silently Matching the Wrong Route
If you're using a catch-all or optional catch-all route ([...slug]), double-check your matching logic isn't accidentally treating an unmatched slug as a match for the first item in an array, or falling through to a default case that happens to be another real post rather than a proper 404.
How to Actually Diagnose It
Don't guess — verify directly:
Fetch the raw HTML, not the rendered page in your browser (which may be showing you client-side hydrated content that masks the bug). Use
curlor a headless fetch:
bash
curl -s https://yoursite.com/blog/your-slug | grep -A 2 "<title"Check what Google actually sees using Google Search Console's URL Inspection tool — "View Crawled Page" shows you the HTML Googlebot received, which is the ground truth that matters for SEO.
Compare the canonical tag, og:url, and title against the URL you requested. If any of them point to a different route, you have this bug.
Prevention Checklist
Never let a slug-lookup function return a default/fallback item — always
notFound()on a miss.Trigger on-demand revalidation when content is published or updated, don't rely solely on time-based ISR windows.
Add an automated test that fetches every known route and asserts the returned title/canonical matches the expected slug — this catches regressions before they hit production.
Periodically audit real crawled pages in Search Console, not just what renders in your local browser.
This class of bug is easy to miss because everything looks fine when you're clicking around the site with a warm cache and client-side navigation. It only shows up clearly when you request the page fresh, the way a crawler does — which is exactly why it's worth building the habit of checking raw server responses, not just the rendered UI, whenever you're debugging SEO issues on a Next.js site.
If your Next.js site has metadata or indexing issues you can't pin down, I help teams debug and fix exactly this kind of thing.