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( +