From b00816663a98ee8513b20d12d604d8d3a64335b6 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Thu, 27 Aug 2026 14:38:19 -0400 Subject: [PATCH] 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. --- .devcontainer/devcontainer.json | 22 --- .mcp.json | 8 -- README.md | 40 ++---- apps/web/app/(auth)/login/page.tsx | 6 +- apps/web/app/(landing)/page.tsx | 6 + apps/web/app/(static)/help/page.tsx | 6 +- apps/web/app/(static)/mcp/page.tsx | 6 +- apps/web/app/(static)/privacy/page.tsx | 7 +- apps/web/app/(static)/terms/page.tsx | 7 +- apps/web/app/[domain]/page.tsx | 31 ++-- apps/web/app/api/og/route.tsx | 7 +- apps/web/app/dashboard/add-domain/page.tsx | 12 ++ apps/web/app/dashboard/layout.tsx | 6 +- apps/web/app/layout.tsx | 14 +- apps/web/app/not-found.tsx | 6 +- apps/web/app/opengraph-image.tsx | 133 ++++++++++++++++++ apps/web/app/settings/layout.tsx | 6 +- .../domain/hosting/hosting-map-skeleton.tsx | 2 +- apps/web/components/domain/screenshot.tsx | 2 +- apps/web/components/ui/map.tsx | 2 +- apps/web/lib/og-utils.ts | 6 + apps/web/lib/seo.test.ts | 122 ++++++++++++++++ apps/web/lib/seo.ts | 102 ++++++++++++++ 23 files changed, 446 insertions(+), 113 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .mcp.json create mode 100644 apps/web/app/opengraph-image.tsx create mode 100644 apps/web/lib/seo.test.ts create mode 100644 apps/web/lib/seo.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index ceec4f45..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -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" - } -} diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index ee5d245d..00000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "next-devtools": { - "command": "npx", - "args": ["-y", "next-devtools-mcp@latest"] - } - } -} diff --git a/README.md b/README.md index ef0e33f3..38968c45 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index a17dda73..0cf35ce5 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -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 ( diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 14fd102d..cdf627c5 100644 --- a/apps/web/app/(landing)/page.tsx +++ b/apps/web/app/(landing)/page.tsx @@ -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 ( diff --git a/apps/web/app/(static)/help/page.tsx b/apps/web/app/(static)/help/page.tsx index e1811074..51e90f0c 100644 --- a/apps/web/app/(static)/help/page.tsx +++ b/apps/web/app/(static)/help/page.tsx @@ -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 = [ { diff --git a/apps/web/app/(static)/mcp/page.tsx b/apps/web/app/(static)/mcp/page.tsx index 5b98ec75..c44bcac6 100644 --- a/apps/web/app/(static)/mcp/page.tsx +++ b/apps/web/app/(static)/mcp/page.tsx @@ -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`; diff --git a/apps/web/app/(static)/privacy/page.tsx b/apps/web/app/(static)/privacy/page.tsx index ce2d195f..edf8f50b 100644 --- a/apps/web/app/(static)/privacy/page.tsx +++ b/apps/web/app/(static)/privacy/page.tsx @@ -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 ( diff --git a/apps/web/app/(static)/terms/page.tsx b/apps/web/app/(static)/terms/page.tsx index b4c8fd02..56752a9e 100644 --- a/apps/web/app/(static)/terms/page.tsx +++ b/apps/web/app/(static)/terms/page.tsx @@ -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 ( diff --git a/apps/web/app/[domain]/page.tsx b/apps/web/app/[domain]/page.tsx index 5a28c9e1..0a5da077 100644 --- a/apps/web/app/[domain]/page.tsx +++ b/apps/web/app/[domain]/page.tsx @@ -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 }> }) { diff --git a/apps/web/app/api/og/route.tsx b/apps/web/app/api/og/route.tsx index eb5ec769..bd32b072 100644 --- a/apps/web/app/api/og/route.tsx +++ b/apps/web/app/api/og/route.tsx @@ -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 }} > diff --git a/apps/web/app/dashboard/add-domain/page.tsx b/apps/web/app/dashboard/add-domain/page.tsx index 6b4df000..1a8c195d 100644 --- a/apps/web/app/dashboard/add-domain/page.tsx +++ b/apps/web/app/dashboard/add-domain/page.tsx @@ -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, diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index 5c1e5232..41e1abcc 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -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) diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index b43ba286..3a6f8c0c 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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", diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index a1354b70..4f640a7d 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -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 ( diff --git a/apps/web/app/opengraph-image.tsx b/apps/web/app/opengraph-image.tsx new file mode 100644 index 00000000..8bc753c1 --- /dev/null +++ b/apps/web/app/opengraph-image.tsx @@ -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( +
+
+
+ +
+
+ {SITE_NAME} +
+
{SITE_TAGLINE}
+
+
+ +
+
+ Inspect any domain. +
+
+ {SITE_DESCRIPTION} +
+
+ +
+
+
+
WHOIS, DNS, SSL, hosting & SEO
+
+
domainstack.io
+
+
+
, + { + ...size, + fonts: [ + { + name: "Geist", + data: geistRegularFont, + style: "normal", + weight: 400, + }, + { + name: "Geist", + data: geistSemiBoldFont, + style: "normal", + weight: 600, + }, + ], + }, + ); +} diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index a4c509c4..af4ae47c 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -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) diff --git a/apps/web/components/domain/hosting/hosting-map-skeleton.tsx b/apps/web/components/domain/hosting/hosting-map-skeleton.tsx index 4c54c86e..60f0efa3 100644 --- a/apps/web/components/domain/hosting/hosting-map-skeleton.tsx +++ b/apps/web/components/domain/hosting/hosting-map-skeleton.tsx @@ -6,7 +6,7 @@ export function HostingMapSkeleton() {
- Loading map... + Loading map…
diff --git a/apps/web/components/domain/screenshot.tsx b/apps/web/components/domain/screenshot.tsx index 7f1c98fe..66b53a1b 100644 --- a/apps/web/components/domain/screenshot.tsx +++ b/apps/web/components/domain/screenshot.tsx @@ -350,7 +350,7 @@ export function Screenshot({ {isLoading ? ( <> - Taking screenshot... + Taking screenshot… ) : blocked ? ( <> diff --git a/apps/web/components/ui/map.tsx b/apps/web/components/ui/map.tsx index d85c4b03..63db714b 100644 --- a/apps/web/components/ui/map.tsx +++ b/apps/web/components/ui/map.tsx @@ -82,7 +82,7 @@ const DefaultLoader = () => (
- Loading map... + Loading map…
); diff --git a/apps/web/lib/og-utils.ts b/apps/web/lib/og-utils.ts index 80892741..6e7fd8b9 100644 --- a/apps/web/lib/og-utils.ts +++ b/apps/web/lib/og-utils.ts @@ -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 { diff --git a/apps/web/lib/seo.test.ts b/apps/web/lib/seo.test.ts new file mode 100644 index 00000000..99945d5f --- /dev/null +++ b/apps/web/lib/seo.test.ts @@ -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.", + }); + }); +}); diff --git a/apps/web/lib/seo.ts b/apps/web/lib/seo.ts new file mode 100644 index 00000000..fce6f572 --- /dev/null +++ b/apps/web/lib/seo.ts @@ -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; + +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, +};