SEO: apex canonical on project pages, add BreadcrumbList + richer Service schema, image schema

This commit is contained in:
Zac Gaetano 2026-05-04 00:01:22 -04:00
parent f4b58f789e
commit fa6cd08f9f

View file

@ -4,6 +4,8 @@ import Image from "next/image";
import { ArrowLeft, ArrowRight, ExternalLink } from "lucide-react"; import { ArrowLeft, ArrowRight, ExternalLink } from "lucide-react";
import { projects, getProjectBySlug } from "@/data/projects"; import { projects, getProjectBySlug } from "@/data/projects";
const SITE_URL = "https://wilddragon.net";
export function generateStaticParams() { export function generateStaticParams() {
return projects.map((project) => ({ return projects.map((project) => ({
slug: project.slug, slug: project.slug,
@ -15,8 +17,11 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
const project = getProjectBySlug(slug); const project = getProjectBySlug(slug);
if (!project) return { title: "Project Not Found" }; if (!project) return { title: "Project Not Found" };
const url = `${SITE_URL}/projects/${slug}`;
const ogImage = project.thumbnail.startsWith("http") ? project.thumbnail : `${SITE_URL}${project.thumbnail}`;
return { return {
title: `${project.client} | Zachary Gaetano — Broadcast Systems Integration`, title: `${project.client} ${project.category} | Broadcast Facility Case Study`,
description: project.summary, description: project.summary,
keywords: [ keywords: [
...project.technologies, ...project.technologies,
@ -27,24 +32,25 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
"Wild Dragon", "Wild Dragon",
"Washington DC", "Washington DC",
"broadcast facility design", "broadcast facility design",
"case study",
], ],
alternates: { alternates: {
canonical: `https://www.wilddragon.net/projects/${slug}`, canonical: url,
}, },
openGraph: { openGraph: {
title: `${project.client} | Zachary Gaetano`, title: `${project.client}${project.category} | Zachary Gaetano`,
description: project.summary, description: project.summary,
url: `https://www.wilddragon.net/projects/${slug}`, url,
siteName: "Wild Dragon", siteName: "Wild Dragon",
type: "website", type: "article",
locale: "en_US", locale: "en_US",
images: [{ url: project.thumbnail, width: 1200, height: 630, alt: `${project.client}${project.category}` }], images: [{ url: ogImage, width: 1200, height: 630, alt: `${project.client}${project.category} broadcast facility` }],
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: `${project.client} | Zachary Gaetano`, title: `${project.client} ${project.category}`,
description: project.summary, description: project.summary,
images: [project.thumbnail], images: [ogImage],
}, },
}; };
} }
@ -57,14 +63,45 @@ export default async function ProjectPage({ params }: { params: Promise<{ slug:
notFound(); notFound();
} }
const url = `${SITE_URL}/projects/${slug}`;
const imageUrl = project.thumbnail.startsWith("http") ? project.thumbnail : `${SITE_URL}${project.thumbnail}`;
const structuredData = { const structuredData = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Service", "@graph": [
name: project.title, {
description: project.summary, "@type": "BreadcrumbList",
serviceType: project.category, itemListElement: [
provider: { "@id": "https://www.wilddragon.net/#organization" }, { "@type": "ListItem", position: 1, name: "Home", item: `${SITE_URL}/` },
areaServed: "United States", { "@type": "ListItem", position: 2, name: "Projects", item: `${SITE_URL}/#projects` },
{ "@type": "ListItem", position: 3, name: project.client, item: url },
],
},
{
"@type": "Service",
"@id": `${url}#service`,
name: project.title,
description: project.summary,
serviceType: project.category,
category: project.category,
provider: { "@id": `${SITE_URL}/#organization` },
areaServed: "United States",
image: imageUrl,
url,
},
{
"@type": "CreativeWork",
"@id": `${url}#case-study`,
name: `${project.client}${project.category}`,
headline: project.title,
description: project.summary,
about: project.technologies,
author: { "@id": `${SITE_URL}/#person` },
publisher: { "@id": `${SITE_URL}/#organization` },
image: imageUrl,
url,
},
],
}; };
const currentIndex = projects.findIndex((p) => p.slug === project.slug); const currentIndex = projects.findIndex((p) => p.slug === project.slug);
@ -76,13 +113,13 @@ export default async function ProjectPage({ params }: { params: Promise<{ slug:
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} /> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
{/* Header bar */} {/* Header bar */}
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-lg shadow-sm py-3.5"> <nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-lg shadow-sm py-3.5" aria-label="Project navigation">
<div className="max-w-6xl mx-auto px-6 flex items-center justify-between"> <div className="max-w-6xl mx-auto px-6 flex items-center justify-between">
<Link href="/#projects" className="flex items-center gap-2 text-[13px] text-muted hover:text-primary transition-colors duration-200 group"> <Link href="/#projects" className="flex items-center gap-2 text-[13px] text-muted hover:text-primary transition-colors duration-200 group">
<ArrowLeft size={15} className="group-hover:-translate-x-0.5 transition-transform duration-200" /> <ArrowLeft size={15} className="group-hover:-translate-x-0.5 transition-transform duration-200" />
Back to Projects Back to Projects
</Link> </Link>
<Link href="/" className="font-mono text-[10px] tracking-[0.2em] uppercase text-muted hover:text-primary transition-colors duration-200"> <Link href="/" className="font-mono text-[10px] tracking-[0.2em] uppercase text-muted hover:text-primary transition-colors duration-200" aria-label="Wild Dragon — home">
Wild Dragon Wild Dragon
</Link> </Link>
</div> </div>
@ -185,11 +222,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ slug:
</section> </section>
{/* Prev / Next navigation */} {/* Prev / Next navigation */}
<section className="border-t border-neutral-200"> <section className="border-t border-neutral-200" aria-label="Project pagination">
<div className="max-w-6xl mx-auto px-6"> <div className="max-w-6xl mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-neutral-200"> <div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-neutral-200">
{prevProject ? ( {prevProject ? (
<Link href={`/projects/${prevProject.slug}`} className="group flex items-center gap-4 py-10 pr-8 hover:bg-neutral-50 transition-colors duration-200"> <Link href={`/projects/${prevProject.slug}`} className="group flex items-center gap-4 py-10 pr-8 hover:bg-neutral-50 transition-colors duration-200" rel="prev">
<ArrowLeft size={18} className="text-neutral-300 group-hover:text-accent group-hover:-translate-x-1 transition-all duration-200 shrink-0" /> <ArrowLeft size={18} className="text-neutral-300 group-hover:text-accent group-hover:-translate-x-1 transition-all duration-200 shrink-0" />
<div> <div>
<p className="font-mono text-[10px] tracking-wider uppercase text-muted mb-1">Previous</p> <p className="font-mono text-[10px] tracking-wider uppercase text-muted mb-1">Previous</p>
@ -199,7 +236,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ slug:
) : <div className="py-10" />} ) : <div className="py-10" />}
{nextProject ? ( {nextProject ? (
<Link href={`/projects/${nextProject.slug}`} className="group flex items-center justify-end gap-4 py-10 pl-8 hover:bg-neutral-50 transition-colors duration-200"> <Link href={`/projects/${nextProject.slug}`} className="group flex items-center justify-end gap-4 py-10 pl-8 hover:bg-neutral-50 transition-colors duration-200" rel="next">
<div className="text-right"> <div className="text-right">
<p className="font-mono text-[10px] tracking-wider uppercase text-muted mb-1">Next</p> <p className="font-mono text-[10px] tracking-wider uppercase text-muted mb-1">Next</p>
<p className="text-sm font-medium text-primary group-hover:text-accent transition-colors duration-200">{nextProject.client}</p> <p className="text-sm font-medium text-primary group-hover:text-accent transition-colors duration-200">{nextProject.client}</p>