SEO: apex canonical on project pages, add BreadcrumbList + richer Service schema, image schema
This commit is contained in:
parent
f4b58f789e
commit
fa6cd08f9f
1 changed files with 56 additions and 19 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue