Omri BarmatsOmri Barmats
 | 8 months ago
In this post, I discuss my approach to adding dynamic meta data such as meta titles, meta descriptions, canonical tags, Open Graph images, and Twitter images to my Next.js 14 project.

How I add dynamic metadata to my Next.js 14 website

To add dynamic metadata to my pages, I use Next.js's built-in generateMetadata function that returns an object with all the relevant properties: title, description, Open Graph images, and canonical. I simply insert this function into my page.tsx file, and Next.js does all the heavy lifting, including a fallback to the next metadata object in the hierarchy when some properties are not set.

Relevant links:
1. Weasker public repo
2. Weasker question page | Code
3. Next.js documentation for Metadata and generateMetadata
4. Weasker root layout code

Set Up Dynamic Metadata

Because Vercel takes SEO seriously, setting up your metadata is straightforward if you follow the documentation. However, I want to share my approach for sending dynamic data and fetching the data for Weasker.com.

First, let's look at an example of what we're trying to achieve. Below is a screenshot of the question page for pixel artist badge holders: What tools do you use for pixel art

If you open the HTML inspector (F12) and search for "title," you will see this line inside the <head> section. This line, along with other lines inside that object, is what we're trying to achieve. If you look closely, you will see other meta properties like "description," "canonical," "author," and more are also defined there.

value.alt

A nicer way to inspect metadata is by using a browser extension. I use an extension called SEO META 1 COPY, available for both Firefox and Chrome. The extension lets you see the meta title, description, Open Graph images, structured data markups, canonical tags, robots tags, and much more for every webpage.

value.alt

This example is a question page from Weasker.com, which is a page with one question followed by many answers. Therefore, the dynamic meta title structure I chose for this page is as follows: H1 title | Amount of answers

In our example, it turns out to be: What Tools Do You Use For Pixel Art? | 7 Answers

The number of answers for each question always changes, so how did I set it up to alter dynamically?

The Question-Page File:

// Importing the Metadata type from Next.js
import { Metadata } from "next";

// Define the types for parameters received from the requested URL
type Props = {
  params: { badge: string; interview: string; question: string };
};

// Exporting the generateMetadata function that leverages Next.js for processing
export async function generateMetadata({ params }: Props): Promise<Metadata> {

  // GraphQL query to fetch data from payload-cms
  const query = `{
    UsersInterviews(
      where: {
        AND: [
          { interviewSlug: { equals: "${params.interview}" } },
          { badgeSlug: { equals: "${params.badge}" } },
        ],
      }
    ) {
      docs {
        // Definition of fields to retrieve
      }
    }
  }`;

  // Fetching necessary data for the Metadata from payload-cms
  const data = await fetchData({
    query,
    method: "POST",
    collection: "UsersInterviews",
    mustHave: ["UsersInterviews"],
  });
    
  // Fallback process: If the fetch fails and no data is retrieved, the metadata defaults to the next level in the hierarchy,
  // typically the layout file's metadata. This ensures that there's always some metadata available for SEO purposes, even if it's not specific.
  if (!data) {
    return {};
  }

  // Extracting the specific question based on the parameter from the URL
  const relevantQuestion = data.data.UsersInterviews.docs[0].interview.questions.find((item) => item.question.seo.slug === params.question).question;

  // Filtering answers relevant to the specific question
  const answers = data.data.UsersInterviews.docs.filter((doc) => doc.answers.some((answer) => answer.questionSlug === params.question));

  // Return an empty object if no relevant question is found, allowing fallback to the next metadata object
  if (!relevantQuestion) {
    return {};
  }

  // Constructing the meta title based on the number of answers and the titles defined in payload-cms
  // If there's more than one answer, display the count of answers; otherwise, just show the medium-length version of the question.
  const metaTitle = answers.length > 1
    ? capitalize(
        relevantQuestion.seo.title
          ? relevantQuestion.seo.title
          : `${relevantQuestion.mediumQuestion} | ${answers.length} Answers`
      )
    : capitalize(
        relevantQuestion.seo.title
          ? relevantQuestion.seo.title
          : relevantQuestion.mediumQuestion
      );

  // Determining the meta description based on the defined descriptions or the long question version
  const metaDescription = relevantQuestion.seo.description || relevantQuestion.longQuestion;
    
  // Dynamically creating the Open Graph image using Next.js ImageResponse
  const ogImage = `${process.env.SITE_URL}/api/og?img=${
    relevantQuestion.seo.image.url ||
    interview.seo.image.url ||
    defaultImages.defaultQuestionImage
  }&title=${metaTitle}`;

  // Setting authors based on users who answered the question
  const authors = answers.map((answer) => ({
    name: answer.user.displayName || answer.user.userName,
    url: `https://www.weasker.com/user/${answer.userSlug}`,
  }));
  
  // Returning the constructed metadata object
  return {
    title: metaTitle,
    description: metaDescription,
    authors: authors,
    openGraph: {
      images: [ogImage],
      type: "website",
      url: `https://www.weasker.com/question/${params.badge}/${params.interview}/${params.question}`,
      title: metaTitle,
      description: metaDescription,
      siteName: process.env.SITE_NAME,
    },
    twitter: {
      card: "summary_large_image",
      title: metaTitle,
      description: metaDescription,
      siteId: "########",
      creator: process.env.SITE_NAME,
      creatorId: "########",
      images: [ogImage],
    },
  };
}

Important Notes about the generateMetadata function:
1. It's a server component, meaning it only works on files that do not have"use client" at the beginning.

2. Use this generateMetadata function only if you want to present dynamic data; otherwise, for static data, you can simply export the metadata object as seen in this example.

The Metadata Fallback

This is an important and cool principle of setting metadata in Next.js. If a page doesn't have metadata set up, then Next.js will use the metadata from the segment above, such as the page layout file. When you define a metadata object on your page, you are overwriting the metadata object defined in your root layout.

For example, if the root layout's metadata object contains:
applicationName: process.env.SITE_NAME

There's no need to define it again at the page level.

And we can see it in our example from above.

What tools do you use for pixel art?

If you once again open the HTML inspector (F12) and search for "application-name" you will come across this line inside the <head> section.

value.alt

And if you look at the code for the question-page above, you will see that nowhere in the object I defined the application-name. And that's the power of the metadata fallback.

Metadata in the root layout

The metadata in the root layout will be the last fallback for all pages and other layouts. You can consider it the default metadata in case fetching the metadata fails.

The example below is from Weasker.com's root layout file, where I didn't need dynamic data, so I used the Metadata object without calling the generateMetadata function.

// Importing the Metadata type from Next.js
import type { Metadata } from "next";

// Constructing the URL for the Open Graph image using 
// Next.js ImageResponse API

const ogImage = `${process.env.SITE_URL}/api/og?img=${defaultImages.weaskerLogoUrl}&preTitle=Interviewing experts&title=weasker.com`;

// Setting the meta title that will be the fallback in cases
// where no meta title was provided
const metaTitle = `${process.env.SITE_NAME} - Interviewing Experts`;

// Defining a meta description that will be the fallback in cases
//  where no meta description was provided
const metaDescription =
  "We interview groups of experts and compare their answers, generating diverse and reliable information sources.";

// Exporting the metadata object. Next.js will now do the heavy lifting
export const metadata: Metadata = {
  applicationName: process.env.SITE_NAME,
  authors: { name: process.env.SITE_NAME, url: process.env.SITE_URL },
  title: metaTitle,
  description: metaDescription,
  openGraph: {
    images: [ogImage],
    type: "website",
    url: process.env.SITE_URL,
    title: metaTitle,
    description: metaDescription,
    siteName: process.env.SITE_NAME,
  },
  twitter: {
    card: "summary_large_image",
    title: metaTitle,
    description: metaDescription,
    siteId: "#######",
    creator: process.env.SITE_NAME,
    creatorId: "#######",
    images: [ogImage],
  },
};

I'm not a fan of defining individual metadata and descriptions for each page, but I know some people believe it's worth it. I tend to think that generating the title and description dynamically allows me to make programmatic changes across the site in case Google changes its preferences, for example.

Click here to see how I set up dynamic structured data markups (SEO schema)