Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Core 12 min read

Metadata & SEO

Title, description, preview image – and who computes them

In one sentence

You export metadata from a page or a layout. When the values only emerge after fetching, you compute them in generateMetadata.

Because Next.js renders your pages on the server, the content is already in the HTML – half the SEO work is done. That leaves the other half: title, description and preview image.

The simple case

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

export const metadata: Metadata = {
  title: "About us",
  description: "Who we are and what we work on.",
};

export default function Page() {
  return <h1>About us</h1>;
}
An exported object is enough. Next.js turns it into the matching tags in the <head>.
app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Example Ltd",
    template: "%s | Example Ltd",     // the page title is slotted in
  },
  description: "Default description for anything without its own.",
  openGraph: { type: "website", locale: "en_US" },
};
You set the defaults in the root layout. Individual pages override only what they need.
Tip Do not forget metadataBase

Without it, image and canonical addresses stay relative – and relative addresses do not work in social preview cards. Set once in the root layout, it applies everywhere.

When the values come from the data

app/blog/[slug]/page.tsx
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await loadPost(slug);

  if (!post) return { title: "Not found" };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: "/blog/" + slug },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.image],
      type: "article",
      publishedTime: post.date,
    },
  };
}
Good to know The fetch only runs once

generateMetadata and the page often fetch the same data. Thanks to Request memoizationThe same `fetch` used several times while building one page only really runs once. the request still goes out only once – so you can write the call twice without a second thought.

The files around it

app/
app/
  icon.pngfavicon
  apple-icon.pngicon for iOS
  opengraph-image.pngpreview image for shared links
  robots.tsrobots.txt
  sitemap.tssitemap.xml
Again: file names instead of configuration. An image next to a page.tsx applies to that page only.
app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await loadAllPosts();

  return [
    { url: "https://example.com", changeFrequency: "weekly", priority: 1 },
    ...posts.map((p) => ({
      url: "https://example.com/blog/" + p.slug,
      lastModified: p.updatedAt,
    })),
  ];
}

What gets forgotten

Reach for it when …
  • +

    A distinct, descriptive description per page – not a repeat of the title

  • +

    alternates.canonical as soon as addresses with parameters exist

  • +

    robots: { index: false } for pages with no content in the HTML – account, internal tools

  • +

    Structured data via JSON-LD for articles, products, FAQs

Skip it when …
  • Metadata in a client component – the export does nothing there

  • Titles over 60 characters; they get cut off in results

  • The same text on every page

Pitfall It does not work in client components

The metadata export is only read in server components. If the file says "use client", nothing happens – with no error message. When your title does not arrive, that is the first thing to check.

In more depth: structured data for search and AI answers optional

Beyond metadata, JSON-LD pays off. It describes in machine-readable form what is on the page – and answer engines like to quote exactly that, because they do not have to guess it from the prose.

app/blog/[slug]/page.tsx
export default async function Page({ params }) {
  const { slug } = await params;
  const post = await loadPost(slug);

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    datePublished: post.date,
    author: { "@type": "Person", name: post.author },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{post.body}</article>
    </>
  );
}
Tip Generating preview images

Instead of producing an image for every post, an opengraph-image.tsx can generate one at runtime – with the title and author on it. It is a small component that Next.js turns into an image.

Does it stick?

4 questions on this lesson. Wrong answers show up in your stats.

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →