Back to CourseBeginner12 min read
Next JS /

Page Titles, Descriptions and Metadata

Give every Next.js page a real title and description with the metadata object, title templates, generateMetadata with awaited params, and the viewport export.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: October 25, 2026
Beginner Topic

Why your pages need metadata

Open Book Nook in a browser and look at the tab. Right now it says Create Next App, on every single page. That is the title create-next-app put in app/layout.js. Search engines, browser tabs, bookmarks and chat apps all read this kind of information, so every Book Nook page should describe itself properly.

Metadata is information about a page, not shown in the page body. It lives in the <head> of the HTML: the <title>, the <meta name="description">, social sharing tags and more. Think of a book in a shop. The story is the page content. The cover, the title on the spine and the blurb on the back are the metadata. They help people decide whether to open it.

In this lesson you will:

  • set a site-wide title and description with the metadata object
  • use a title template so every page title ends with “| Book Nook”
  • give each book page its own title with generateMetadata
  • set the browser theme colour with the viewport export
  • learn the rules (and errors) that come with Cache Components

You need the Book Nook project from earlier lessons, with lib/books.js and the /books/[id] page from Dynamic Routes and params.

How Next.js builds the head

You never write a <head> tag yourself in the App Router. Instead, you export metadata from a layout.js or page.js file, and Next.js turns it into the right tags. Even with no metadata at all, two tags are always added for you:

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

There are two ways to export metadata:

Export Use it when
export const metadata = { ... } The values never change (an About page, the site name)
export async function generateMetadata() { ... } The values depend on data, such as which book is open

One important rule: both only work in Server Components. A file that starts with 'use client' cannot export metadata. You will see the error for this at the end of the lesson.

Step 1: set the site defaults in the root layout

Open app/layout.js. Here is the file create-next-app made:

// app/layout.js
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col">{children}</body>
    </html>
  );
}

The metadata export is already there. Change it to describe Book Nook:

// app/layout.js
export const metadata = {
  title: {
    template: '%s | Book Nook',
    default: 'Book Nook',
  },
  description: 'A small online bookshop with hand-picked stories for every reader.',
};

This title is an object instead of a plain string. It has two parts:

  • template is a pattern for the pages below this layout. %s is a placeholder. A child page with the title 'All books' gets the full title All books | Book Nook.
  • default is the title used when a page does not set its own. A template always needs a default, or Next.js has nothing to fall back to.

Save, run npm run dev, and open http://localhost:3000. The tab now says Book Nook, because the home page sets no title of its own.

Tip: To see the real tags, right click the page and choose View Page Source (or open the browser developer tools and look inside <head>). That shows exactly what search engines receive.

Step 2: give a static page its own title

Open app/books/page.js and add a metadata export above the component:

// app/books/page.js
import Link from 'next/link';
import { getBooks } from '@/lib/books';

export const metadata = {
  title: 'All books',
  description: 'Browse every book in the Book Nook shop.',
};

export default async function BooksPage() {
  const books = await getBooks();
  return (
    <main className="p-8">
      <h1 className="text-2xl font-bold">All books</h1>
      <ul>
        {books.map((book) => (
          <li key={book.id}>
            <Link href={`/books/${book.id}`}>{book.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Visit /books. The page source now contains:

<title>All books | Book Nook</title>
<meta name="description" content="Browse every book in the Book Nook shop."/>

The page wrote only 'All books'. The template from the root layout added the rest.

Escaping the template with absolute

Sometimes a page should not use the template. A special sale page, for example, might want its own wording. Use absolute:

// app/sale/page.js
export const metadata = {
  title: {
    absolute: 'Summer Sale: every classic half price',
  },
};

Now the sale page title is exactly that text, with no “| Book Nook” added.

Where templates apply

A template only affects child route segments, never the segment where it is defined. app/layout.js and app/page.js live in the same folder, so they are the same segment. That gives two surprises:

  • A title set in app/page.js (the home page) does not get the template from app/layout.js. Write the full title there, or rely on default.
  • Pages in subfolders, like app/books/page.js and app/books/[id]/page.js, are children, so they do get the template.

And a template written in a page.js file does nothing at all, because a page has no children.

Step 3: let metadata merge down the tree

Next.js reads metadata from the root layout down to the page, and merges it. A page’s value replaces the parent’s value for the same key. Keys the page does not set are inherited.

// app/layout.js (inside the metadata object)
openGraph: {
  siteName: 'Book Nook',
  type: 'website',
},
// app/about/page.js
export const metadata = {
  title: 'About us',
};

The About page sets only title, so it keeps the openGraph values from the root layout. But watch out: the merge is shallow. If a page sets its own openGraph object, the whole parent openGraph object is replaced, not combined key by key:

// app/about/page.js
export const metadata = {
  title: 'About us',
  openGraph: {
    title: 'About Book Nook',
    // siteName and type from the layout are now gone!
  },
};

If you want to keep shared values, put them in a small shared object and spread it in:

// app/shared-metadata.js
export const sharedOpenGraph = {
  siteName: 'Book Nook',
  type: 'website',
};
// app/about/page.js
import { sharedOpenGraph } from '../shared-metadata';

export const metadata = {
  title: 'About us',
  openGraph: {
    ...sharedOpenGraph,
    title: 'About Book Nook',
  },
};

You will use openGraph properly in the next lesson, Open Graph Images and Social Cards.

Step 4: one title per book with generateMetadata

Every book page needs a different title: “The Hobbit | Book Nook”, “Dune | Book Nook” and so on. A fixed object cannot do that, so use the generateMetadata function. It receives the same params as the page.

In Next.js 16, params is a Promise, a value that arrives a little later. You must await it before reading id, exactly like in the page component.

// app/books/[id]/page.js
import { notFound } from 'next/navigation';
import { getBook, getBooks } from '@/lib/books';

export async function generateStaticParams() {
  const books = await getBooks();
  return books.map((book) => ({ id: book.id }));
}

export async function generateMetadata({ params }) {
  const { id } = await params;
  const book = await getBook(id);

  if (!book) {
    return { title: 'Book not found' };
  }

  return {
    title: book.title,
    description: book.description,
  };
}

export default async function BookPage({ params }) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();

  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold">{book.title}</h1>
      <p>by {book.author}</p>
      <p>{book.description}</p>
    </main>
  );
}

Open /books/the-hobbit and view the source:

<title>The Hobbit | Book Nook</title>
<meta name="description" content="Bilbo Baggins leaves his cosy home for an adventure with dwarves and a dragon."/>

A few things to notice:

  • generateMetadata returns the same kind of object you would export as metadata.
  • The template still applies. You return 'The Hobbit' and get The Hobbit | Book Nook.
  • generateStaticParams is the function from Pre-rendering Dynamic Routes with generateStaticParams. Because the book ids are known at build time, the metadata is worked out at build time too and goes straight into the prerendered HTML.
  • getBook(id) is called twice, once for metadata and once for the page. That is fine: since Caching with Cache Components and use cache, getBook starts with 'use cache', so the second call reuses the stored result instead of asking the database again. Being cached also matters for the next step: an uncached database call inside generateMetadata would stop the build.

Reading the parent metadata

generateMetadata also gets a second argument, parent, a Promise of the metadata already worked out by the layouts above. You rarely need it, but it is handy when you want to add to a parent value instead of replacing it:

// app/books/[id]/page.js
export async function generateMetadata({ params }, parent) {
  const { id } = await params;
  const book = await getBook(id);
  const parentMetadata = await parent;
  const keywords = parentMetadata.keywords ?? [];

  return {
    title: book?.title ?? 'Book not found',
    keywords: [...keywords, book?.genre ?? 'books'],
  };
}

Step 5: metadata and Cache Components

The course project has Cache Components turned on. That gives generateMetadata one extra rule. If it reads runtime data (like cookies() or headers()) or does uncached data fetching, while the rest of the page could be prerendered, the build stops and asks you to decide.

For example, this tries to put the visitor’s name in the cart title:

// app/cart/page.js
import { cookies } from 'next/headers';

export async function generateMetadata() {
  const name = (await cookies()).get('name')?.value ?? 'friend';
  return { title: 'Cart for ' + name };
}

npm run build prints:

Route "/cart": Next.js encountered uncached or runtime data in `generateMetadata()`.

This route's metadata is blocked, but the rest of its content can be prerendered.

Ways to fix this:
  - [static] Use a static metadata export instead of `generateMetadata()`
  - [cache] Cache the metadata with `"use cache"` in `generateMetadata()` (does not apply to `connection()`)
  - [dynamic] Render a marker component that calls `await connection()` inside `<Suspense>` on the page

The easiest fix is usually the first one: a cart title does not need the visitor’s name, so a plain export const metadata = { title: 'Your cart' } is better. If metadata reads from a database but not from the request, add 'use cache' at the top of generateMetadata, as you learned in Caching with Cache Components and use cache.

Good to know: On pages that really are rendered per request, Next.js can stream metadata: it sends the page first and adds the tags when generateMetadata finishes. Bots that cannot run JavaScript (like the ones behind link previews) still get the tags inside <head>. You do not need to configure anything.

Step 6: theme colour with the viewport export

Some head tags are about the browser window, not the content. The colour of the address bar on phones is one. These go in a separate export called viewport:

// app/layout.js
export const viewport = {
  themeColor: '#7c2d12',
};

The page source now includes:

<meta name="theme-color" content="#7c2d12"/>

You can even pick a colour for light and dark mode:

// app/layout.js
export const viewport = {
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#fffbeb' },
    { media: '(prefers-color-scheme: dark)', color: '#1c1917' },
  ],
};

Why a separate export? Older versions put themeColor and viewport inside metadata. That is deprecated since Next.js 14, so if you see it in an old tutorial, move it to export const viewport. Leave width and initialScale alone: the default viewport tag is already correct. Like metadata, viewport has a function version, generateViewport, for values that depend on data.

Step 7: autocomplete in a JavaScript project

The metadata object has many fields (keywords, authors, robots, alternates, icons and more). You do not need to memorise them. Add a JSDoc comment, a special comment that tells VS Code what type a value has, and the editor will suggest every field:

// app/layout.js
/** @type {import('next').Metadata} */
export const metadata = {
  title: {
    template: '%s | Book Nook',
    default: 'Book Nook',
  },
};

Type a new key inside the object and VS Code lists the options. The full list is in the generateMetadata reference.

Common mistakes

Exporting metadata from a client component

Error: You are attempting to export "metadata" from a component marked with "use client", which is disallowed. "metadata" must be resolved on the server before the page component is rendered. Keep your page as a Server Component and move Client Component logic to a separate file.

Keep page.js as a Server Component that exports the metadata, and move the interactive part into its own file (for example components/CartItems.js with 'use client'). Then render that component from the page. See Mixing Server and Client Components.

Exporting both metadata and generateMetadata

Error: "metadata" and "generateMetadata" cannot be exported at the same time, please keep one of them.

Pick one per file. If any value depends on data, use generateMetadata for all of them.

Forgetting to await params

// Wrong: params is a Promise, so params.id is undefined
export async function generateMetadata({ params }) {
  const book = await getBook(params.id);
}

Every title becomes “Book not found”. Write const { id } = await params; first.

A template that never shows up

You added template: '%s | Book Nook' to app/layout.js, then set title: 'Home' in app/page.js, and the tab says just “Home”. That is expected: the root layout and the home page are the same segment, and templates only apply to children. Also remember a template written in a page.js file does nothing, because pages have no children.

Quick recap

  • Metadata is information about a page that goes in <head>: title, description, social tags and more.
  • Export metadata (an object) or generateMetadata (an async function) from a layout.js or page.js. Never both, and only in Server Components.
  • title: { template: '%s | Book Nook', default: 'Book Nook' } in the root layout gives every child page a consistent title. absolute skips the template.
  • Metadata merges from the root down. The merge is shallow, so a page’s openGraph replaces the layout’s.
  • In generateMetadata, await params before reading id, just like in the page.
  • With Cache Components, reading cookies or headers in generateMetadata on an otherwise static page is a build error. Use static metadata or 'use cache'.
  • Theme colour belongs in export const viewport, not in metadata.
script.js

// app/books/[id]/page.js: a book page with its own title and description
import { notFound } from 'next/navigation';
import { getBook, getBooks } from '@/lib/books';
// Prerender every book at build time (Cache Components is on)
export async function generateStaticParams() {
  const books = await getBooks();
  return books.map((book) => ({ id: book.id }));
}
// Runs on the server and fills the head tags for this book
export async function generateMetadata({ params }) {
  const { id } = await params; // params is a Promise in Next.js 16
  const book = await getBook(id);
  if (!book) {
    return { title: 'Book not found' };
  }
  return {
    title: book.title, // becomes "The Hobbit | Book Nook" thanks to the template
    description: book.description,
  };
}
export default async function BookPage({ params }) {
  const { id } = await params;
  const book = await getBook(id);
  if (!book) notFound();
  return (
    <main className="p-8">
      <h1 className="text-3xl font-bold">{book.title}</h1>
      <p>by {book.author}</p>
      <p>{book.description}</p>
    </main>
  );
}
// --- next file: app/layout.js (only the exports that changed)
// export const metadata = {
//   title: { template: '%s | Book Nook', default: 'Book Nook' },
//   description: 'A small online bookshop with hand-picked stories for every reader.',
// };
// export const viewport = { themeColor: '#7c2d12' };

Was this explanation helpful?

Your feedback helps us improve the content for everyone.