From 0263a59c6ab43838b6211ab313663f81a35b7e00 Mon Sep 17 00:00:00 2001 From: Rami Bitar Date: Sun, 9 Aug 2026 09:35:57 -0400 Subject: [PATCH] Add page metadata across every route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root layout exported no metadata, so routes without their own export rendered no title at all, and no route had a description. Add a root metadata block with a title template, Open Graph and Twitter defaults, and fill in the routes that were missing tags. - config/site.ts: description and env-driven absolute url for metadataBase - home, /collections and /shop/collections: titles and descriptions - products/[handle] and collections/[handle]: generateMetadata off the existing catalog fetchers, with canonical and OG image - existing titles: drop the hand-written " — Shop" suffix now that the root template appends it Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x5jT4fJJCnqPx3umEvXRk --- app/account/activate/[id]/[token]/page.tsx | 2 +- app/account/login/page.tsx | 2 +- app/account/page.tsx | 2 +- app/account/recover/page.tsx | 2 +- app/account/register/page.tsx | 2 +- app/account/reset/[id]/[token]/page.tsx | 2 +- app/collections/[handle]/page.tsx | 48 ++++++++++++++++++++++ app/collections/page.tsx | 6 +++ app/layout.tsx | 24 +++++++++++ app/page.tsx | 9 ++++ app/policies/[handle]/page.tsx | 7 +++- app/products/[handle]/page.tsx | 42 +++++++++++++++++++ app/search/page.tsx | 3 +- app/shop/collections/page.tsx | 8 ++++ config/site.ts | 9 ++++ 15 files changed, 160 insertions(+), 8 deletions(-) diff --git a/app/account/activate/[id]/[token]/page.tsx b/app/account/activate/[id]/[token]/page.tsx index c6d5dc1..e6f4994 100644 --- a/app/account/activate/[id]/[token]/page.tsx +++ b/app/account/activate/[id]/[token]/page.tsx @@ -1,6 +1,6 @@ import AccountForm from '@/components/shopify/account-form'; -export const metadata = { title: 'Activate your account — Shop' }; +export const metadata = { title: 'Activate your account' }; // Shopify's emailed activation link is /account/activate/{id}/{token}. export default async function Page({ diff --git a/app/account/login/page.tsx b/app/account/login/page.tsx index e816ce4..cf9fdf0 100644 --- a/app/account/login/page.tsx +++ b/app/account/login/page.tsx @@ -1,6 +1,6 @@ import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; -export const metadata = { title: 'Sign in — Shop' }; +export const metadata = { title: 'Sign in' }; export default function Page() { return ( diff --git a/app/account/page.tsx b/app/account/page.tsx index be24866..0780012 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -3,7 +3,7 @@ import OrderHistory from '@/components/shopify/order-history'; import { getSessionToken } from '@/services/shopify/session'; import { getCustomer } from '@/services/shopify/customer'; -export const metadata = { title: 'Order history — Shop' }; +export const metadata = { title: 'Order history' }; export default async function Page() { const token = await getSessionToken(); diff --git a/app/account/recover/page.tsx b/app/account/recover/page.tsx index 1d921c3..e4f7fa1 100644 --- a/app/account/recover/page.tsx +++ b/app/account/recover/page.tsx @@ -1,6 +1,6 @@ import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; -export const metadata = { title: 'Reset password — Shop' }; +export const metadata = { title: 'Reset password' }; export default function Page() { return ( diff --git a/app/account/register/page.tsx b/app/account/register/page.tsx index 5403b7d..f6a1df0 100644 --- a/app/account/register/page.tsx +++ b/app/account/register/page.tsx @@ -1,6 +1,6 @@ import AccountForm, { AccountFormLink } from '@/components/shopify/account-form'; -export const metadata = { title: 'Create account — Shop' }; +export const metadata = { title: 'Create account' }; export default function Page() { return ( diff --git a/app/account/reset/[id]/[token]/page.tsx b/app/account/reset/[id]/[token]/page.tsx index a80cb3b..4588210 100644 --- a/app/account/reset/[id]/[token]/page.tsx +++ b/app/account/reset/[id]/[token]/page.tsx @@ -1,6 +1,6 @@ import AccountForm from '@/components/shopify/account-form'; -export const metadata = { title: 'Set a new password — Shop' }; +export const metadata = { title: 'Set a new password' }; // Shopify's emailed reset link is /account/reset/{id}/{token}. export default async function Page({ diff --git a/app/collections/[handle]/page.tsx b/app/collections/[handle]/page.tsx index 1ad0e03..2a9ee95 100644 --- a/app/collections/[handle]/page.tsx +++ b/app/collections/[handle]/page.tsx @@ -1,4 +1,52 @@ +import type { Metadata } from 'next'; import CollectionDetail from '@/components/shopify/collection-detail'; +import { getCollectionProductsPage } from '@/services/shopify/catalog'; +import { truncate } from '@/lib/utils'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ handle: string }>; +}): Promise { + const { handle } = await params; + + // No collection-only query exists, so ask for the smallest page of products + // and use just the collection node off it. + const { collection } = await getCollectionProductsPage(handle, { + first: 1, + }).catch(() => ({ collection: null })); + + if (!collection) return { title: 'Collection' }; + + const description = collection.description + ? truncate(collection.description, 160) + : `Shop the ${collection.title} collection.`; + + return { + title: collection.title, + description, + alternates: { canonical: `/collections/${collection.handle}` }, + openGraph: { + title: collection.title, + description, + url: `/collections/${collection.handle}`, + images: collection.image + ? [ + { + url: collection.image.url, + alt: collection.image.altText ?? collection.title, + }, + ] + : undefined, + }, + twitter: { + card: 'summary_large_image', + title: collection.title, + description, + images: collection.image ? [collection.image.url] : undefined, + }, + }; +} export default function Page() { return ; diff --git a/app/collections/page.tsx b/app/collections/page.tsx index e5da58c..fca43b8 100644 --- a/app/collections/page.tsx +++ b/app/collections/page.tsx @@ -1,5 +1,11 @@ +import type { Metadata } from 'next'; import Collections from '@/components/shopify/collections'; +export const metadata: Metadata = { + title: 'Collections', + description: 'Browse every collection in the store.', +}; + export default function Page() { return ; } diff --git a/app/layout.tsx b/app/layout.tsx index 56b06f6..7c15563 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { Metadata } from 'next'; import './globals.css'; import { Geist, Geist_Mono } from 'next/font/google'; import Header from '@/components/shopify/header'; @@ -16,6 +17,29 @@ const geistMono = Geist_Mono({ variable: '--font-geist-mono', }); +// Routes only set their own `title`; the template appends the store name, and +// everything else here is inherited unless a page overrides it. +export const metadata: Metadata = { + metadataBase: new URL(site.url), + title: { + default: site.storeName, + template: `%s — ${site.storeName}`, + }, + description: site.description, + openGraph: { + type: 'website', + siteName: site.storeName, + title: site.storeName, + description: site.description, + url: '/', + }, + twitter: { + card: 'summary_large_image', + title: site.storeName, + description: site.description, + }, +}; + export default function RootLayout({ children, }: { diff --git a/app/page.tsx b/app/page.tsx index d1578b8..1d780ab 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,13 @@ +import type { Metadata } from 'next'; import Products from '@/components/shopify/products'; +import { site } from '@/config/site'; + +// The home page keeps the bare store name, so `title.absolute` opts out of the +// root template rather than rendering "Shop — Shop". +export const metadata: Metadata = { + title: { absolute: site.storeName }, + description: site.description, +}; export default function Page() { return ( diff --git a/app/policies/[handle]/page.tsx b/app/policies/[handle]/page.tsx index b5a0b96..84fb748 100644 --- a/app/policies/[handle]/page.tsx +++ b/app/policies/[handle]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from 'next/navigation'; import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies'; +import { site } from '@/config/site'; export function generateStaticParams() { return POLICY_HANDLES.map((handle) => ({ handle })); @@ -13,8 +14,12 @@ export async function generateMetadata({ const { handle } = await params; const policy = await getShopPolicy(handle); + if (!policy) return { title: 'Policy' }; + return { - title: policy ? `${policy.title} — Shop` : 'Policy — Shop', + title: policy.title, + description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`, + alternates: { canonical: `/policies/${policy.handle}` }, }; } diff --git a/app/products/[handle]/page.tsx b/app/products/[handle]/page.tsx index 7832c72..e61df74 100644 --- a/app/products/[handle]/page.tsx +++ b/app/products/[handle]/page.tsx @@ -1,5 +1,47 @@ +import type { Metadata } from 'next'; import ProductDetail from '@/components/shopify/product-detail'; import ProductRecommendations from '@/components/shopify/product-recommendations'; +import { getProduct } from '@/services/shopify/catalog'; +import { truncate } from '@/lib/utils'; +import { site } from '@/config/site'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ handle: string }>; +}): Promise { + const { handle } = await params; + + // ProductDetail fetches client-side and renders its own empty state, so a + // failed lookup here falls back to generic tags rather than a 500. + const product = await getProduct(handle).catch(() => null); + if (!product) return { title: 'Product' }; + + const description = product.description + ? truncate(product.description, 160) + : site.description; + const image = product.images.edges[0]?.node; + + return { + title: product.title, + description, + alternates: { canonical: `/products/${product.handle}` }, + openGraph: { + title: product.title, + description, + url: `/products/${product.handle}`, + images: image + ? [{ url: image.url, alt: image.altText ?? product.title }] + : undefined, + }, + twitter: { + card: 'summary_large_image', + title: product.title, + description, + images: image ? [image.url] : undefined, + }, + }; +} export default function Page() { return ( diff --git a/app/search/page.tsx b/app/search/page.tsx index fc5450d..a6f19de 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -2,7 +2,8 @@ import { Suspense } from 'react'; import SearchResults from '@/components/shopify/search-results'; export const metadata = { - title: 'Search — Shop', + title: 'Search', + description: 'Search the catalogue by product name, description or type.', }; export default function Page() { diff --git a/app/shop/collections/page.tsx b/app/shop/collections/page.tsx index e5da58c..4c680c9 100644 --- a/app/shop/collections/page.tsx +++ b/app/shop/collections/page.tsx @@ -1,5 +1,13 @@ +import type { Metadata } from 'next'; import Collections from '@/components/shopify/collections'; +export const metadata: Metadata = { + title: 'Collections', + description: 'Browse every collection in the store.', + // Same listing as /collections; point crawlers at the canonical route. + alternates: { canonical: '/collections' }, +}; + export default function Page() { return ; } diff --git a/config/site.ts b/config/site.ts index 539a10a..86824f0 100644 --- a/config/site.ts +++ b/config/site.ts @@ -3,6 +3,15 @@ import type { NavLink } from '@/components/shopify/header'; /** Chrome shared by every route — rendered once in the root layout. */ export const site = { storeName: 'Shop', + description: + 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.', + // Absolute origin behind `metadataBase`, so Open Graph images resolve to full + // URLs. Vercel injects VERCEL_PROJECT_PRODUCTION_URL on deployed builds. + url: + process.env.NEXT_PUBLIC_SITE_URL ?? + (process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : 'http://localhost:3000'), logoUrl: '', copyright: '© 2026 Shop. All rights reserved.', navLinks: [