TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Add Dynamic Open Graph Metadata to Every Next.js Blog Post

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20267 min read6 topics
How to Add Dynamic Open Graph Metadata to Every Next.js Blog Post
Explore the details
#Next.js#Open Graph#SEO#App Router#Metadata#Social Sharing
October 26, 20267 min read

A blog post shared in a chat or social feed needs its own title, description, canonical URL, and preview image—not the same metadata as every other page. In the Next.js App Router, generate those values from the dynamic route’s post data with generateMetadata.

This tutorial builds that pattern for /blog/[slug], sets a site-wide metadataBase, explains metadata inheritance, and shows how to check the rendered tags.

Key Takeaways

  • Use generateMetadata in a route segment when metadata depends on data such as a post slug.
  • Set metadataBase in a shared layout so relative canonical and image URLs resolve against your site’s origin.
  • A child route’s nested metadata object, such as openGraph, replaces the parent object; include the fields the child still needs.
  • Give every post a canonical URL and its own Open Graph and Twitter card values.
  • Check the HTML delivered for a post and use a social-sharing debugger to inspect the image preview.

1. Set shared metadata and the site origin

In the App Router, metadata can be exported from a layout or page, or generated with generateMetadata. Put site-wide defaults in the root layout. metadataBase lets Next.js resolve relative metadata URLs, including canonicals and social images, against the configured site origin.

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL(process.env.SITE_URL ?? "https://www.example.com"),
  title: {
    default: "Field Notes",
    template: "%s | Field Notes",
  },
  description: "Practical articles about building for the web.",
  openGraph: {
    siteName: "Field Notes",
    locale: "en_US",
    type: "website",
    images: [
      {
        url: "/images/og-default.png",
        width: 1200,
        height: 630,
        alt: "Field Notes",
      },
    ],
  },
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Set SITE_URL to the production origin, for example https://blog.example.com, in each deployment environment. The fallback keeps the example runnable, but replace www.example.com before publishing. The image path above corresponds to public/images/og-default.png.

2. Make post data available by slug

The metadata function and the page need the same post information. This small data module is a self-contained example; a real blog can replace it with a database or CMS query. Keep the post’s title, description, publication date, and preview image in the content record so metadata does not drift from the article.

// lib/posts.ts
export type Post = {
  slug: string;
  title: string;
  description: string;
  publishedAt: string;
  image: string;
  body: string;
};

const posts: Post[] = [
  {
    slug: "clean-url-design",
    title: "Designing Cleaner URLs",
    description: "Practical choices for readable, durable page URLs.",
    publishedAt: "2025-01-15T09:00:00.000Z",
    image: "/images/posts/clean-url-design.png",
    body: "A good URL helps people and software understand where a page belongs.",
  },
];

export function getPost(slug: string): Post | undefined {
  return posts.find((post) => post.slug === slug);
}

Place the image at public/images/posts/clean-url-design.png. A path beginning with / is served from the site root. Use a distinct, representative image for each post when possible; a generic fallback is less informative in a share preview.

3. Generate metadata from the dynamic route

For Next.js 15 and later, dynamic route params are asynchronous. Await them before looking up the post. The example below uses the same lookup in generateMetadata and the page, returns a 404 for an unknown slug, and sets canonical, Open Graph, and Twitter metadata.

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

type PageProps = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({
  params,
}: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = getPost(slug);

  if (!post) {
    notFound();
  }

  const path = `/blog/${post.slug}`;

  return {
    title: post.title,
    description: post.description,
    alternates: {
      canonical: path,
    },
    openGraph: {
      type: "article",
      siteName: "Field Notes",
      locale: "en_US",
      url: path,
      title: post.title,
      description: post.description,
      publishedTime: post.publishedAt,
      images: [
        {
          url: post.image,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.description,
      images: [post.image],
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <main>
      <article>
        <h1>{post.title}</h1>
        <p>{post.body}</p>
      </article>
    </main>
  );
}

With the example origin, the canonical URL resolves to https://www.example.com/blog/clean-url-design, and the image resolves to https://www.example.com/images/posts/clean-url-design.png. Change the origin and content to match your site. In Next.js 14, route params are synchronous; use that version’s documented PageProps shape rather than copying the Promise-based type unchanged.

The title can use the root layout’s template, producing a title such as Designing Cleaner URLs | Field Notes. The openGraph object in this route includes siteName and locale intentionally: nested metadata is merged by key, but nested objects such as openGraph are not deep-merged field by field. Defining openGraph here replaces the parent’s openGraph object, including its default image. If a post has no dedicated image, add the fallback image explicitly to this route’s returned openGraph.images rather than assuming the parent image will be retained.

Canonical metadata and social sharing metadata serve different purposes. alternates.canonical identifies the preferred URL for the page; Open Graph and Twitter fields describe how the page should appear when shared. Keep the canonical pointed at the public, preferred version of the article—not a preview host, tracking URL, or alternate query-string URL.

4. Verify the metadata for more than one post

Check both the page response and the rendered result. Replace the example host with your local or production origin as appropriate.

  1. Start the app and visit a known post, such as /blog/clean-url-design.
  2. Inspect the HTML source or use curl to check that the response contains a title, description, canonical link, og:title, og:description, og:url, og:image, and Twitter card tags. The generated tags may use attributes such as property for Open Graph and name for Twitter.
  3. Confirm that the canonical and image URLs are absolute and use the intended production origin. Check that the image URL loads and points to the correct post artwork.
  4. Visit an unknown slug and confirm the route returns the not-found experience rather than metadata for another article.
  5. Test at least two real posts. Verify that their titles, descriptions, canonicals, and images differ as expected.
  6. Paste the deployed URL into the relevant social platform’s sharing debugger or preview tool. A correct HTML tag does not guarantee a correct preview if the image is unavailable to crawlers or the platform has cached an older result.

A basic response check can start with:

curl -sS https://www.example.com/blog/clean-url-design \
  | grep -Eo '<title>[^<]*|<link[^>]+rel="canonical"[^>]*|property="og:[^"]+"[^>]*|name="twitter:[^"]+"[^>]*'

This is a quick diagnostic, not a complete HTML parser or a guarantee that every social crawler will process the page identically. If tags are missing or stale, check the deployed response, route data, image path, environment-specific SITE_URL, and crawler access to the page and image. A browser’s live DOM can differ from the original response, so inspect the response source as well.

Common Mistakes

  • Using one hard-coded title for every post. Generate metadata from the same record used to render the article.
  • Forgetting the origin. Without a correct metadataBase, relative URLs can resolve incorrectly or fail to become the intended public URLs.
  • Expecting nested metadata to deep-merge. A child openGraph object replaces the parent’s openGraph object. Include every inherited field the child still needs.
  • Setting only Open Graph fields. Some platforms and consumers use Twitter card metadata; set it explicitly when you want to control that presentation.
  • Using a canonical URL with tracking parameters or the wrong host. Set one stable preferred URL for each post.
  • Pointing at a missing or unsuitable image. Check the deployed asset, dimensions, and appearance at small preview sizes.
  • Copying route types across Next.js versions. The dynamic route params API differs between Next.js versions; follow the documentation for the version installed in the project.

Best Practices

  • Generate page metadata from canonical content data, not a separate list of manually maintained tags.
  • Keep descriptions useful and specific to each article; avoid copying the same text across the blog.
  • Use stable, absolute production URLs after metadataBase resolution, and set the deployment origin explicitly.
  • Provide a useful image with descriptive alt text, and check that social crawlers can fetch it without authentication.
  • Handle missing posts consistently in both metadata generation and page rendering.
  • Test the rendered metadata after deployment, especially when changing a CMS, domain, or image pipeline.

Conclusion

For an App Router blog, generateMetadata can turn each slug’s post data into page-specific canonical and social sharing tags. Pair it with a correctly configured metadataBase, account for metadata inheritance, and verify the actual deployed HTML and preview image.

References