Metadata + SEO cheatsheet.

Static metadata

// app/page.tsx
export const metadata = {
  title: "Home",
  description: "Welcome to my app",
};

Title templates

// app/layout.tsx
export const metadata = {
  title: {
    default: "MyApp",
    template: "%s — MyApp",       // child pages get "X — MyApp"
  },
  description: "Default description",
};
// app/posts/page.tsx
export const metadata = { title: "Posts" };
// Renders: "Posts — MyApp"

Dynamic metadata

// app/posts/[id]/page.tsx
export async function generateMetadata({ params }) {
  const { id } = await params;
  const post = await fetchPost(id);
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.image],
    },
  };
}

Open Graph

export const metadata = {
  openGraph: {
    title: "Title",
    description: "Description",
    url: "https://example.com/page",
    siteName: "MyApp",
    images: [
      { url: "https://example.com/og.png", width: 1200, height: 630, alt: "..." },
    ],
    locale: "en_US",
    type: "article",
    publishedTime: "2026-01-01",
    authors: ["A"],
    tags: ["tag1"],
  },
};

Twitter

twitter: {
  card: "summary_large_image",
  title: "...",
  description: "...",
  images: ["https://example.com/og.png"],
  creator: "@user",
},

Icons

export const metadata = {
  icons: {
    icon: "/icon.png",
    apple: "/apple-icon.png",
    shortcut: "/shortcut.png",
  },
};

Or use file convention:

app/
├── favicon.ico
├── icon.png         # any size
├── icon.tsx         # dynamic icon
└── apple-icon.png

Dynamic icon

// app/icon.tsx
import { ImageResponse } from "next/og";

export const size = { width: 32, height: 32 };
export const contentType = "image/png";

export default function Icon() {
  return new ImageResponse(
    <div style={{ width: "100%", height: "100%", background: "black", color: "white", display: "flex", alignItems: "center", justifyContent: "center" }}>
      M
    </div>,
    { ...size },
  );
}

OG image (dynamic)

// app/posts/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function OG({ params }) {
  const { id } = await params;
  const post = await fetchPost(id);
  
  return new ImageResponse(
    <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", padding: 80, background: "white" }}>
      <h1 style={{ fontSize: 64 }}>{post.title}</h1>
      <p>{post.excerpt}</p>
    </div>,
    { ...size },
  );
}

Generated per post at request time.

sitemap.ts

// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await db.post.findMany();
  
  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      changeFrequency: "weekly",
      priority: 1,
    },
    ...posts.map((p) => ({
      url: `https://example.com/posts/${p.id}`,
      lastModified: p.updatedAt,
      changeFrequency: "monthly",
      priority: 0.7,
    })),
  ];
}

Available at /sitemap.xml.

robots.ts

// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow: ["/api/", "/admin/"] },
    ],
    sitemap: "https://example.com/sitemap.xml",
  };
}

Canonical URL

export const metadata = {
  alternates: {
    canonical: "https://example.com/posts/123",
    languages: {
      "en-US": "https://example.com/en/posts/123",
      "es-ES": "https://example.com/es/posts/123",
    },
  },
};

Structured data (JSON-LD)

export default async function Page({ params }) {
  const post = await fetchPost(params.id);
  
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    image: post.image,
    datePublished: post.publishedAt,
    author: { "@type": "Person", name: post.author },
  };
  
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <Article post={post} />
    </>
  );
}

metadataBase

// app/layout.tsx
export const metadata = {
  metadataBase: new URL("https://example.com"),
  // Now relative URLs resolve against this
};

Without this, absolute URLs throw warnings.

viewport

export const viewport = {
  themeColor: "#000",
  colorScheme: "dark light",
  width: "device-width",
  initialScale: 1,
};

manifest.ts (PWA)

// app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "MyApp",
    short_name: "MyApp",
    description: "...",
    start_url: "/",
    display: "standalone",
    background_color: "#fff",
    theme_color: "#000",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
    ],
  };
}

Common mistakes

  • Missing metadataBase → relative URLs warnings.
  • Dynamic metadata without await on params (Next 15+).
  • Same title everywhere — kills search ranking.
  • OG image 404 → social previews break.
  • Forgetting sitemap → poor crawl.

Read this next

If you want my SEO + OG patterns, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .