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
Self Learner
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
metadataobject - 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
viewportexport - 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:
templateis a pattern for the pages below this layout.%sis a placeholder. A child page with the title'All books'gets the full title All books | Book Nook.defaultis 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 fromapp/layout.js. Write the full title there, or rely ondefault. - Pages in subfolders, like
app/books/page.jsandapp/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:
generateMetadatareturns the same kind of object you would export asmetadata.- The template still applies. You return
'The Hobbit'and getThe Hobbit | Book Nook. generateStaticParamsis 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,getBookstarts 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 insidegenerateMetadatawould 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
generateMetadatafinishes. 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) orgenerateMetadata(an async function) from alayout.jsorpage.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.absoluteskips the template.- Metadata merges from the root down. The merge is shallow, so a page’s
openGraphreplaces the layout’s. - In
generateMetadata,await paramsbefore readingid, just like in the page. - With Cache Components, reading cookies or headers in
generateMetadataon an otherwise static page is a build error. Use static metadata or'use cache'. - Theme colour belongs in
export const viewport, not inmetadata.
// 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.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready