refactor: centralize SEO metadata management

- Replaced hardcoded metadata in various pages with a centralized `createMetadata` function for consistency and maintainability.
- Introduced `rootMetadata` and `notFoundMetadata` for default SEO settings.
- Added new Open Graph image generation functionality in `opengraph-image.tsx`.
- Updated existing pages to utilize the new SEO structure, ensuring proper metadata handling across the application.
This commit is contained in:
2026-08-27 14:38:19 -04:00
parent 6f8e25cf1e
commit b00816663a
23 changed files with 446 additions and 113 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"image": "mcr.microsoft.com/devcontainers/universal:5",
"postCreateCommand": ". ${NVM_DIR}/nvm.sh && nvm install && corepack enable && CI=true pnpm install",
"customizations": {
"vscode": {
"extensions": [
"bradlc.vscode-tailwindcss",
"oxc.oxc-vscode",
"vitest.explorer",
"vercel.turbo-vsc"
],
"settings": {
"typescript.tsdk": "node_modules/typescript/lib"
}
}
},
"forwardPorts": [3000],
"containerEnv": {
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0",
"NEXT_TELEMETRY_DISABLED": "1"
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"next-devtools": {
"command": "npx",
"args": ["-y", "next-devtools-mcp@latest"]
}
}
}
+13 -27
View File
@@ -34,39 +34,25 @@
- **Next.js 16** (App Router), **React 19**, **TypeScript**
- **Tailwind CSS v4** + [**Base UI**](https://base-ui.com/)
- **tRPC** + **TanStack Query** & **TanStack Table**
- **Postgres** (PlanetScale) + **Drizzle ORM** + **Upstash Redis** (rate limiting)
- **Better Auth** (OAuth)
- **Polar** (subscriptions)
- [**Workflow DevKit**](https://useworkflow.dev/) (background jobs)
- [**AI SDK**](https://ai-sdk.dev/) + [**AI Gateway**](https://vercel.com/ai-gateway)
- **Resend** + **React Email**
- [**tRPC**](https://trpc.io/) + [**TanStack Query**](https://tanstack.com/query/latest) & [**TanStack Table**](https://tanstack.com/table/latest)
- [**PlanetScale Postgres**](https://planetscale.com/postgres) + [**Drizzle**](https://orm.drizzle.team/) + [**Upstash Redis**](https://upstash.com/) (rate limiting)
- [**Better Auth**](https://www.better-auth.com) (OAuth)
- [**Polar**](https://polar.sh/) (subscriptions)
- [**Workflow SDK**](https://workflow-sdk.dev/) (background jobs)
- [**AI SDK**](https://ai-sdk.dev/) + [**Vercel AI Gateway**](https://vercel.com/ai-gateway) (Stacky bot)
- [**Resend**](https://resend.com/) (email notifications)
- [**mapcn**](https://mapcn.vercel.app/) + [**CARTO Basemaps**](https://docs.carto.com/faqs/carto-basemaps) (web maps)
- [**Logo.dev**](https://www.logo.dev) (provider icons)
- [**IPLocate.io**](https://www.iplocate.io/) (geolocation)
- [**PostHog**](https://posthog.com/) (telemetry)
- **Vercel** (Edge Config, Blob Storage)
- [**mapcn**](https://mapcn.vercel.app/) + [**CARTO Basemaps**](https://docs.carto.com/faqs/carto-basemaps)
- [**Logo.dev**](https://www.logo.dev)
- [**IPLocate.io**](https://www.iplocate.io/)
- **PostHog** (analytics)
- **Turborepo** (monorepo)
- **Vitest** + **Playwright** (testing), **oxlint/oxfmt** (linting)
## Project Structure
This is a **[Turborepo](https://turborepo.dev/docs) monorepo**:
```
domainstack.io/
├── apps/
│ └── web/ # Next.js application
├── packages/
│ ├── constants/ # Shared constants (enums, TTLs, validation)
│ ├── types/ # Shared TypeScript types
│ ├── typescript-config/ # Shared TypeScript configs
│ └── ui/ # Shared UI primitives
└── turbo.json # Turborepo task configuration
```
## Development
This is a **[Turborepo](https://turborepo.dev/docs) monorepo**.
### 1. Clone & install
```bash
+4 -2
View File
@@ -1,13 +1,15 @@
import type { Metadata } from "next";
import { LoginContent } from "@/components/auth/login-content";
import { createMetadata } from "@/lib/seo";
import { Card } from "@domainstack/ui/card";
import { cn } from "@domainstack/ui/utils";
export const metadata: Metadata = {
export const metadata: Metadata = createMetadata({
path: "/login",
title: "Sign In",
description: "Sign in to track your domains and receive health alerts.",
};
});
export default function LoginPage() {
return (
+6
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { AnnouncementPill } from "@/components/landing/announcement-pill";
@@ -5,6 +6,11 @@ import { HomeHero } from "@/components/landing/home-hero";
import { HomeSearchClient } from "@/components/search/home-search-client";
import { DomainSuggestions } from "@/components/search/home-search-suggestions";
import { HomeSearchSuggestionsSkeleton } from "@/components/search/home-search-suggestions-skeleton";
import { createMetadata } from "@/lib/seo";
export const metadata: Metadata = createMetadata({
path: "/",
});
export default function LandingPage() {
return (
+4 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { createMetadata } from "@/lib/seo";
import { PLAN_QUOTAS } from "@domainstack/constants";
import { PRO_TIER_INFO } from "@domainstack/polar/products";
import {
@@ -10,11 +11,12 @@ import {
AccordionTrigger,
} from "@domainstack/ui/accordion";
export const metadata: Metadata = {
export const metadata: Metadata = createMetadata({
path: "/help",
title: "Help & FAQ",
description:
"Get answers to common questions about Domainstack, domain tracking, notifications, and more.",
};
});
const faqSections = [
{
+4 -2
View File
@@ -9,6 +9,7 @@ import type { Metadata } from "next";
import Image from "next/image";
import { BetaBadge } from "@/components/beta-badge";
import { createMetadata } from "@/lib/seo";
import {
Accordion,
AccordionContent,
@@ -18,10 +19,11 @@ import {
import { Badge } from "@domainstack/ui/badge";
import { CodeBlock } from "@domainstack/ui/code-block";
export const metadata: Metadata = {
export const metadata: Metadata = createMetadata({
path: "/mcp",
title: "MCP (Beta)",
description: "Connect AI assistants like Claude to Domainstack for domain intelligence lookups.",
};
});
const MCP_URL = `${process.env.NEXT_PUBLIC_BASE_URL}/api/transport/mcp`;
+5 -2
View File
@@ -1,10 +1,13 @@
import { IconExternalLink } from "@tabler/icons-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
import { createMetadata } from "@/lib/seo";
export const metadata: Metadata = createMetadata({
path: "/privacy",
title: "Privacy Policy",
description: "Learn how Domainstack collects, uses, and protects your personal information.",
};
});
export default function PrivacyPage() {
return (
+5 -2
View File
@@ -1,10 +1,13 @@
import { IconExternalLink } from "@tabler/icons-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
import { createMetadata } from "@/lib/seo";
export const metadata: Metadata = createMetadata({
path: "/terms",
title: "Terms of Service",
description: "Terms and conditions for using Domainstack's domain intelligence platform.",
};
});
export default function TermsPage() {
return (
+12 -19
View File
@@ -3,6 +3,8 @@ import { notFound, redirect } from "next/navigation";
import { DomainReportClient } from "@/components/domain/report-client";
import { toRegistrableDomain } from "@/lib/normalize-domain";
import { OG_IMAGE_SIZE } from "@/lib/og-utils";
import { createMetadata, notFoundMetadata } from "@/lib/seo";
export async function generateMetadata({
params,
@@ -14,40 +16,31 @@ export async function generateMetadata({
const registrable = toRegistrableDomain(decoded);
if (!registrable) {
// workaround, should match metadata from not-found.tsx
return {
title: "Not Found",
description: "The page you're looking for doesn't exist.",
};
return notFoundMetadata;
}
return {
const imageUrl = `/api/og?domain=${encodeURIComponent(registrable)}`;
return createMetadata({
path: `/${registrable}`,
title: {
absolute: `${registrable} — Domain Report`,
},
description: `Domainstack report for ${registrable}: WHOIS lookup, DNS & SSL scan, HTTP headers, hosting & email provider data, and SEO metadata.`,
alternates: {
canonical: `/${registrable}`,
},
openGraph: {
title: `${registrable} — Domain Report`,
description: `Domainstack report for ${registrable}: WHOIS lookup, DNS & SSL scan, HTTP headers, hosting & email provider data, and SEO metadata.`,
images: [
{
url: `/api/og?domain=${encodeURIComponent(registrable)}`,
width: 1200,
height: 630,
url: imageUrl,
width: OG_IMAGE_SIZE.width,
height: OG_IMAGE_SIZE.height,
alt: `Domainstack — Domain Report for ${registrable}`,
},
],
},
twitter: {
card: "summary_large_image",
title: `${registrable} — Domain Report`,
description: `Domainstack report for ${registrable}: WHOIS lookup, DNS & SSL scan, HTTP headers, hosting & email provider data, and SEO metadata.`,
images: [`/api/og?domain=${encodeURIComponent(registrable)}`],
images: [imageUrl],
},
};
});
}
export default async function DomainPage({ params }: { params: Promise<{ domain: string }> }) {
+3 -4
View File
@@ -5,13 +5,13 @@ import { type NextRequest, NextResponse } from "next/server";
import { Logo } from "@/components/logo";
import { toRegistrableDomain } from "@/lib/normalize-domain";
import { hexToRGBA, loadGoogleFont } from "@/lib/og-utils";
import { hexToRGBA, loadGoogleFont, OG_BACKGROUND_IMAGE, OG_IMAGE_SIZE } from "@/lib/og-utils";
import { createCaller } from "@/server/routers/_app";
import { createLogger } from "@domainstack/logger";
import type { ProviderRef } from "@domainstack/types";
import { normalizeDomainInput } from "@domainstack/utils/domain";
const SIZE = { width: 1200, height: 630 };
const SIZE = OG_IMAGE_SIZE;
const logger = createLogger({ source: "api/og" });
@@ -154,8 +154,7 @@ export async function GET(request: NextRequest) {
height: "100%",
display: "flex",
position: "relative",
backgroundImage:
"linear-gradient(346deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 22%,rgba(140, 140, 140,0.04) 22%, rgba(140, 140, 140,0.04) 69%,rgba(225, 225, 225,0.04) 69%, rgba(225, 225, 225,0.04) 100%),linear-gradient(31deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 42%,rgba(140, 140, 140,0.04) 42%, rgba(140, 140, 140,0.04) 85%,rgba(225, 225, 225,0.04) 85%, rgba(225, 225, 225,0.04) 100%),linear-gradient(55deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 13%,rgba(140, 140, 140,0.04) 13%, rgba(140, 140, 140,0.04) 72%,rgba(225, 225, 225,0.04) 72%, rgba(225, 225, 225,0.04) 100%),linear-gradient(90deg, rgb(0,0,0),rgb(0,0,0))",
backgroundImage: OG_BACKGROUND_IMAGE,
fontFamily: "Geist", // must match fonts[].name
}}
>
@@ -1,9 +1,21 @@
import { IconArrowLeft } from "@tabler/icons-react";
import type { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";
import { AddDomainPageClient } from "@/components/dashboard/add-domain/add-domain-page-client";
import { AddDomainSkeleton } from "@/components/dashboard/add-domain/add-domain-skeleton";
import { createMetadata } from "@/lib/seo";
export const metadata: Metadata = createMetadata({
path: "/dashboard/add-domain",
title: "Add Domain",
description: "Add a domain to track registration, DNS, SSL, and hosting changes.",
robots: {
index: false,
follow: false,
},
});
export default async function AddDomainPage({
searchParams,
+4 -2
View File
@@ -5,16 +5,18 @@ import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Suspense } from "react";
import { DashboardSkeleton } from "@/components/dashboard/dashboard-skeleton";
import { createMetadata } from "@/lib/seo";
import { auth } from "@domainstack/auth/server";
export const metadata: Metadata = {
export const metadata: Metadata = createMetadata({
path: "/dashboard",
title: "Dashboard",
description: "Manage your tracked domains and notification settings.",
robots: {
index: false,
follow: false,
},
};
});
async function ProtectedDashboardLayout({ children }: { children: React.ReactNode }) {
// Server-side auth check - requires runtime data (headers)
+2 -12
View File
@@ -9,21 +9,11 @@ import { CookiePromptGeofenced } from "@/components/consent/cookie-prompt-geofen
import { AppFooter } from "@/components/layout/app-footer";
import { AppHeader } from "@/components/layout/app-header";
import { Toaster } from "@/components/ui/sonner";
import { rootMetadata } from "@/lib/seo";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "Domainstack — Domain Intelligence Made Easy",
template: "%s — Domainstack",
},
description:
"Instant lookups for WHOIS, DNS, hosting, certificates, SEO and more, plus free domain tracking and change alerts.",
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"),
alternates: {
canonical: "/",
},
};
export const metadata: Metadata = rootMetadata;
export const viewport: Viewport = {
width: "device-width",
+2 -4
View File
@@ -2,6 +2,7 @@ import { IconFileX } from "@tabler/icons-react";
import type { Metadata } from "next";
import { SearchClient } from "@/components/search/search-client";
import { notFoundMetadata } from "@/lib/seo";
import {
Empty,
EmptyContent,
@@ -11,10 +12,7 @@ import {
EmptyTitle,
} from "@domainstack/ui/empty";
export const metadata: Metadata = {
title: "Not Found",
description: "The page you're looking for doesn't exist.",
};
export const metadata: Metadata = notFoundMetadata;
export default function NotFound() {
return (
+133
View File
@@ -0,0 +1,133 @@
import { ImageResponse } from "next/og";
import { Logo } from "@/components/logo";
import { loadGoogleFont, OG_BACKGROUND_IMAGE, OG_IMAGE_SIZE } from "@/lib/og-utils";
import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE, SITE_TITLE } from "@/lib/seo";
export const alt = SITE_TITLE;
export const size = OG_IMAGE_SIZE;
export const contentType = "image/png";
export default async function Image() {
const [geistRegularFont, geistSemiBoldFont] = await Promise.all([
loadGoogleFont("Geist", 400),
loadGoogleFont("Geist", 600),
]);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
position: "relative",
backgroundImage: OG_BACKGROUND_IMAGE,
fontFamily: "Geist",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
height: "100%",
padding: "56px 64px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Logo
width={48}
height={48}
style={{
color: "#EAEFF7",
display: "block",
}}
/>
<div style={{ display: "flex", flexDirection: "column" }}>
<div
style={{
fontSize: 22,
color: "#EAEFF7",
letterSpacing: 0.3,
fontWeight: 600,
}}
>
{SITE_NAME}
</div>
<div style={{ fontSize: 14, color: "#AAB3C2" }}>{SITE_TAGLINE}</div>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div
style={{
display: "flex",
fontSize: 64,
lineHeight: 1.1,
fontWeight: 600,
color: "#EAEFF7",
letterSpacing: -1.2,
textShadow: "0 2px 16px rgba(0,0,0,0.35)",
maxWidth: 980,
}}
>
Inspect any domain.
</div>
<div
style={{
display: "flex",
fontSize: 26,
lineHeight: 1.5,
color: "#AAB3C2",
maxWidth: 900,
}}
>
{SITE_DESCRIPTION}
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid rgba(255,255,255,0.08)",
paddingTop: 18,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
width: 10,
height: 10,
borderRadius: 999,
background: "rgba(0, 212, 255, 1)",
boxShadow: "0 0 20px rgba(0, 212, 255, 0.66)",
}}
/>
<div style={{ color: "#AAB3C2", fontSize: 18 }}>WHOIS, DNS, SSL, hosting & SEO</div>
</div>
<div style={{ color: "#EAEFF7", fontSize: 18 }}>domainstack.io</div>
</div>
</div>
</div>,
{
...size,
fonts: [
{
name: "Geist",
data: geistRegularFont,
style: "normal",
weight: 400,
},
{
name: "Geist",
data: geistSemiBoldFont,
style: "normal",
weight: 600,
},
],
},
);
}
+4 -2
View File
@@ -8,18 +8,20 @@ import {
SettingsSkeletonPanels,
SettingsSkeletonTabsList,
} from "@/components/settings/settings-skeleton";
import { createMetadata } from "@/lib/seo";
import { auth } from "@domainstack/auth/server";
import { Card } from "@domainstack/ui/card";
import { Skeleton } from "@domainstack/ui/skeleton";
export const metadata: Metadata = {
export const metadata: Metadata = createMetadata({
path: "/settings",
title: "Settings",
description: "Manage your subscription and notification preferences.",
robots: {
index: false,
follow: false,
},
};
});
async function ProtectedSettingsLayout({ children }: { children: React.ReactNode }) {
// Server-side auth check - requires runtime data (headers)
@@ -6,7 +6,7 @@ export function HostingMapSkeleton() {
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<div className="flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Spinner className="size-4" />
Loading map...
Loading map
</div>
</div>
</div>
+1 -1
View File
@@ -350,7 +350,7 @@ export function Screenshot({
{isLoading ? (
<>
<Spinner />
Taking screenshot...
Taking screenshot
</>
) : blocked ? (
<>
+1 -1
View File
@@ -82,7 +82,7 @@ const DefaultLoader = () => (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
<div className="flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Spinner className="size-4" />
Loading map...
Loading map
</div>
</div>
);
+6
View File
@@ -1,5 +1,11 @@
import { cacheLife } from "next/cache";
export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;
/** Dark geometric background used by default and per-domain OG images. */
export const OG_BACKGROUND_IMAGE =
"linear-gradient(346deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 22%,rgba(140, 140, 140,0.04) 22%, rgba(140, 140, 140,0.04) 69%,rgba(225, 225, 225,0.04) 69%, rgba(225, 225, 225,0.04) 100%),linear-gradient(31deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 42%,rgba(140, 140, 140,0.04) 42%, rgba(140, 140, 140,0.04) 85%,rgba(225, 225, 225,0.04) 85%, rgba(225, 225, 225,0.04) 100%),linear-gradient(55deg, rgba(55, 55, 55,0.04) 0%, rgba(55, 55, 55,0.04) 13%,rgba(140, 140, 140,0.04) 13%, rgba(140, 140, 140,0.04) 72%,rgba(225, 225, 225,0.04) 72%, rgba(225, 225, 225,0.04) 100%),linear-gradient(90deg, rgb(0,0,0),rgb(0,0,0))";
// Load a Google Font from the Google Fonts API
// Adapted from https://github.com/brianlovin/briOS/blob/f72dc33a11194de45c80337b22be4560da62ad7e/src/lib/og-utils.tsx#L32
export async function loadGoogleFont(font: string, weight: number): Promise<ArrayBuffer> {
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { SITE_NAME, SITE_TITLE, createMetadata, notFoundMetadata, rootMetadata } from "./seo";
describe("createMetadata", () => {
it("sets canonical and og:url from the page path", () => {
const metadata = createMetadata({
path: "/help",
title: "Help & FAQ",
description: "Answers to common questions.",
});
expect(metadata.title).toBe("Help & FAQ");
expect(metadata.description).toBe("Answers to common questions.");
expect(metadata.alternates?.canonical).toBe("/help");
expect(metadata.openGraph).toMatchObject({
type: "website",
locale: "en_US",
siteName: SITE_NAME,
url: "/help",
title: "Help & FAQ",
description: "Answers to common questions.",
});
expect(metadata.twitter).toMatchObject({
card: "summary_large_image",
title: "Help & FAQ",
description: "Answers to common questions.",
});
});
it("copies absolute titles onto Open Graph and Twitter", () => {
const metadata = createMetadata({
path: "/example.com",
title: { absolute: "example.com — Domain Report" },
description: "A domain report.",
});
expect(metadata.openGraph).toMatchObject({
title: "example.com — Domain Report",
description: "A domain report.",
});
expect(metadata.twitter).toMatchObject({
title: "example.com — Domain Report",
description: "A domain report.",
});
});
it("lets page-level Open Graph and Twitter fields override defaults", () => {
const metadata = createMetadata({
path: "/example.com",
title: { absolute: "example.com — Domain Report" },
description: "A domain report.",
openGraph: {
title: "Custom OG title",
description: "Custom OG description",
images: [{ url: "/api/og?domain=example.com" }],
},
twitter: {
title: "Custom Twitter title",
images: ["/api/og?domain=example.com"],
},
});
expect(metadata.openGraph).toMatchObject({
siteName: SITE_NAME,
type: "website",
url: "/example.com",
title: "Custom OG title",
description: "Custom OG description",
images: [{ url: "/api/og?domain=example.com" }],
});
expect(metadata.twitter).toMatchObject({
card: "summary_large_image",
title: "Custom Twitter title",
description: "A domain report.",
images: ["/api/og?domain=example.com"],
});
});
it("omits canonical and og:url when no path is provided", () => {
const metadata = createMetadata({
title: "Not Found",
robots: { index: false, follow: false },
});
expect(metadata.alternates).toBeUndefined();
expect(metadata.openGraph).toMatchObject({
siteName: SITE_NAME,
title: "Not Found",
});
expect(metadata.openGraph).not.toHaveProperty("url");
});
});
describe("root metadata", () => {
it("does not pin a site-wide canonical", () => {
expect(rootMetadata.applicationName).toBe(SITE_NAME);
expect(rootMetadata.title).toEqual({
default: SITE_TITLE,
template: `%s — ${SITE_NAME}`,
});
expect(rootMetadata.alternates).toBeUndefined();
expect(rootMetadata.openGraph).toEqual({
type: "website",
locale: "en_US",
siteName: SITE_NAME,
});
});
});
describe("not-found metadata", () => {
it("marks the page as noindex", () => {
expect(notFoundMetadata.robots).toEqual({
index: false,
follow: false,
});
expect(notFoundMetadata.openGraph).toMatchObject({
title: "Not Found",
description: "The page you're looking for doesn't exist.",
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
export const SITE_NAME = "Domainstack";
export const SITE_TAGLINE = "Domain Intelligence Made Easy";
export const SITE_TITLE = `${SITE_NAME}${SITE_TAGLINE}`;
export const SITE_DESCRIPTION =
"Instant lookups for WHOIS, DNS, hosting, certificates, SEO and more, plus free domain tracking and change alerts.";
const openGraphDefaults = {
type: "website",
locale: "en_US",
siteName: SITE_NAME,
} as const satisfies Metadata["openGraph"];
const twitterDefaults = {
card: "summary_large_image",
} as const satisfies Metadata["twitter"];
export type CreateMetadataInput = {
/**
* Pathname for `rel=canonical` and `og:url`, e.g. `/help` or `/github.com`.
* Resolved against `metadataBase` from the root layout.
*/
path?: string;
} & Omit<Metadata, "metadataBase">;
function socialTitle(title: Metadata["title"]): string | undefined {
if (typeof title === "string") return title;
if (!title || typeof title !== "object") return undefined;
if ("absolute" in title && title.absolute) return title.absolute;
if ("default" in title && title.default) return title.default;
return undefined;
}
/**
* Builds page metadata with site-wide Open Graph / Twitter defaults applied.
*
* `title` and `description` are copied onto `og:*` and `twitter:*` unless the
* page already set those fields. Next.js replaces parent `openGraph` and
* `alternates` objects entirely when a child sets them, so every page should
* go through this helper instead of relying on layout inheritance.
*/
export function createMetadata({
path,
title,
description,
openGraph,
twitter,
alternates,
...rest
}: CreateMetadataInput): Metadata {
const canonical = alternates?.canonical ?? path;
const ogTitle = socialTitle(title);
return {
...rest,
...(title !== undefined ? { title } : {}),
...(description !== undefined ? { description } : {}),
...(canonical || alternates
? {
alternates: {
...alternates,
...(canonical ? { canonical } : {}),
},
}
: {}),
openGraph: {
...openGraphDefaults,
...(path ? { url: path } : {}),
...(ogTitle ? { title: ogTitle } : {}),
...(description ? { description } : {}),
...openGraph,
},
twitter: {
...twitterDefaults,
...(ogTitle ? { title: ogTitle } : {}),
...(description ? { description } : {}),
...twitter,
},
};
}
export const notFoundMetadata = createMetadata({
title: "Not Found",
description: "The page you're looking for doesn't exist.",
robots: {
index: false,
follow: false,
},
});
export const rootMetadata: Metadata = {
applicationName: SITE_NAME,
title: {
default: SITE_TITLE,
template: `%s — ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"),
openGraph: openGraphDefaults,
twitter: twitterDefaults,
};