Metadata and SEO

Give every page a title, a description and a social card, without repeating yourself.

10of 16
≈ 10Min read
7Parts

A static export

Next renders it into the <head>. There is no <Head> component and no next/head in the App Router — metadata is data, exported from the route.

in a layout or a page
export const metadata = {
  title: "Doors",
  description: "Made to order, in eight weeks.",
  alternates: { canonical: "https://domvybe.com/doors" },
};

export default function Page() { … }

The title template

The template applies to the segments below the layout that defined it, never to the layout itself — which is what default is for.

set the pattern once, in the root layout
// app/layout.tsx
export const metadata = {
  title: {
    default: "DOMVYBE — doors made to order",   // when a page sets nothing
    template: "%s · DOMVYBE",                   // when a page sets a title
  },
};

// app/doors/page.tsx
export const metadata = { title: "Doors" };            // → "Doors · DOMVYBE"

// and to escape the pattern
export const metadata = { title: { absolute: "DOMVYBE" } };   // → "DOMVYBE"

Dynamic metadata

generateMetadata, beside the page
export async function generateMetadata({ params }) {
  const { id } = await params;
  const door = await getDoor(id);          // deduplicated with the page's own fetch

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

  return {
    title: door.name,
    description: door.summary,
    openGraph: { images: [door.image] },
  };
}
Do not fetch twice

The page and generateMetadata usually need the same record. fetch is deduplicated for you; a database call is not — wrap it in cache() or you have doubled every query on the route.

The social card

  • 1200 × 630 is the size everything renders well at.
  • Without metadataBase, relative image URLs are dropped — a card with no image is the usual symptom.
  • Generate the image if it is per-page: opengraph-image.tsx beside the route renders one with JSX at request time.
what a link actually looks like when shared
export const metadata = {
  metadataBase: new URL("https://domvybe.com"),    // makes relative URLs absolute
  openGraph: {
    title: "Painted door",
    description: "Five days, made to order.",
    url: "/doors/painted",
    siteName: "DOMVYBE",
    images: [{ url: "/og/painted.png", width: 1200, height: 630 }],
    type: "website",
  },
  twitter: { card: "summary_large_image" },
};

The file conventions

a file beside the route, and it is wired up
app/
  favicon.ico
  icon.png                  → <link rel="icon">
  apple-icon.png
  opengraph-image.png       → the card image for this segment
  opengraph-image.tsx       → or generate it with JSX
  robots.txt  /  robots.ts
  sitemap.xml /  sitemap.ts

A sitemap that is generated

app/sitemap.js
export default async function sitemap() {
  const doors = await getDoors();

  return [
    { url: "https://domvybe.com", lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
    ...doors.map((d) => ({
      url: `https://domvybe.com/doors/${d.id}`,
      lastModified: d.updatedAt,
      changeFrequency: "monthly",
      priority: 0.8,
    })),
  ];
}
  • A unique title and description per page. Duplicates across a site are worse than a missing one.
  • A canonical URL, especially when filters and tracking parameters produce the same page at many addresses.
  • Real HTML: a server-rendered page is indexable, a client-only one is a gamble.
  • Speed, on a phone. Core Web Vitals are a ranking signal and a real experience.
  • And nothing here helps a page with nothing worth reading on it.

Practice