Skip to content

Commit 1cb8654

Browse files
authored
feat(registry): generalize component-page SEO (prose + FAQ + related) (#487)
## What From Search Console: component pages earn impressions but rank page 1-2, and the pages ranking best are exactly the ~13 AI components with hand-written copy (`ai-seo.ts`). This generalizes that treatment to the wider catalog — the striking-distance and vllnt-distinctive pages the data flags (e.g. **"react callout"** = 22 impressions at pos 79). ## Changes (2 files, +850) - **New `lib/component-seo.ts`** — general counterpart to `ai-seo.ts`. Hand-written, **source-verified** (each entry authored after reading the actual component) SEO for **26 components**: title, meta description, a usage prose paragraph, **3 FAQs**, and related slugs. Targets: marquee, data-table, callout, live-cursor, edge-label, canvas-view, anchor-port, workspace-switcher, viewport-bookmarks, timeline-scrubber, gantt-chart, quiz, checklist, search-dialog, segmented-control, border-beam, carousel, toggle, select, command, hover-card, form, sheet, dropdown-menu, calendar, table. - **`components/[slug]/page.tsx`** consumes it: - title/description precedence: `ai-seo` → `component-seo` → registry (AI wedge unchanged). - a "What it is & when to use" prose block (mirrors the AI block; links to the component's family). - **FAQPage structured data + a visible FAQ** — `searchAppearance` is currently empty (zero rich results); this is the GEO / AI-overview citation hook. - a **Related components** section — internal linking, curated slugs with a same-family-siblings fallback so *every* component page gains related links. ## Why (data) `docs/seo/gsc-improvement-plan.md`. The AI-wedge pages already rank pos 5-7 with this exact treatment; the rest of the catalog had generic `"{Name} - VLLNT UI"` titles + one thin sentence. This is the proven lever. ## Validation - `eslint` clean, `tsc --noEmit` 0 errors (workspace deps installed in an isolated worktree). - 26 entries assembled from 6 parallel authoring passes; all slugs + related verified against `component-metadata.json`. - Full build + preview via CI; will spot-check a component page (e.g. `/components/callout`) renders the FAQ + Related sections on the preview. ## Not included (separate concerns) - Off-site authority (shadcn registry index, Context7) — not code in this repo. - More components can be added incrementally to `component-seo.ts` using the same shape. Closes #486 Co-authored-by: bntvllnt <bntvllnt@users.noreply.github.com>
1 parent ec8c0bb commit 1cb8654

2 files changed

Lines changed: 850 additions & 3 deletions

File tree

apps/registry/app/[locale]/components/[slug]/page.tsx

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import { ShareEmbedBar } from "@/components/share-embed-bar";
2020
import { type Locale, routing } from "@/i18n/routing";
2121
import { getAiSeo } from "@/lib/ai-seo";
2222
import componentMetadata from "@/lib/component-metadata.json";
23+
import { getComponentSeo } from "@/lib/component-seo";
2324
import {
2425
breadcrumbLd,
26+
faqPageLd,
2527
jsonLdScriptAttributes,
2628
softwareSourceCodeLd,
2729
} from "@/lib/jsonld";
@@ -91,9 +93,13 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
9193
const meta = metadata_map[slug];
9294
const category = getCategoryForComponent(slug);
9395
const aiSeo = getAiSeo(slug);
96+
const componentSeo = getComponentSeo(slug);
9497
const title = meta?.title ?? component.title;
9598
const description =
96-
aiSeo?.description ?? meta?.description ?? component.description;
99+
aiSeo?.description ??
100+
componentSeo?.description ??
101+
meta?.description ??
102+
component.description;
97103
const pathname = `/components/${slug}`;
98104

99105
const ogParameters = {
@@ -113,7 +119,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
113119
},
114120
description,
115121
openGraph: generateOGMetadata(ogParameters, { locale, pathname }),
116-
title: aiSeo?.title ?? `${title} - VLLNT UI`,
122+
title: aiSeo?.title ?? componentSeo?.title ?? `${title} - VLLNT UI`,
117123
twitter: generateTwitterMetadata(ogParameters),
118124
};
119125
}
@@ -132,9 +138,14 @@ export default async function ComponentPage(props: Props) {
132138

133139
const meta = metadata_map[slug];
134140
const aiSeo = getAiSeo(slug);
141+
const componentSeo = getComponentSeo(slug);
135142
const displayTitle = meta?.title ?? component.title ?? component.name;
136143
const displayDescription =
137-
aiSeo?.description ?? meta?.description ?? component.description ?? "";
144+
aiSeo?.description ??
145+
componentSeo?.description ??
146+
meta?.description ??
147+
component.description ??
148+
"";
138149
const playgroundExample = getPlaygroundExample(component);
139150
const registryPackageVersion = getRegistryPackageVersion(registry.version);
140151

@@ -195,6 +206,23 @@ export default async function ComponentPage(props: Props) {
195206
? groupedComponents.find((group) => group.category === componentCategory)
196207
: undefined;
197208

209+
const relatedSlugs = (
210+
componentSeo && componentSeo.related.length > 0
211+
? componentSeo.related
212+
: (familyGroup?.items ?? []).map((item) => item.name)
213+
)
214+
.filter((name) => name !== component.name)
215+
.slice(0, 6);
216+
const relatedComponents = relatedSlugs
217+
.map((name) =>
218+
registry.items.find(
219+
(item): item is RegistryComponent =>
220+
item.name === name && item.type === "registry:component",
221+
),
222+
)
223+
.filter((item): item is RegistryComponent => item !== undefined)
224+
.map((item) => ({ name: item.name, title: item.title ?? item.name }));
225+
198226
const sections = [
199227
{ id: "installation", title: "Installation" },
200228
...(meta?.defaultStoryId ? [{ id: "preview", title: "Preview" }] : []),
@@ -203,6 +231,10 @@ export default async function ComponentPage(props: Props) {
203231
...(component.dependencies && component.dependencies.length > 0
204232
? [{ id: "dependencies", title: "Dependencies" }]
205233
: []),
234+
...(componentSeo?.faqs.length ? [{ id: "faq", title: "FAQ" }] : []),
235+
...(relatedComponents.length > 0
236+
? [{ id: "related", title: "Related" }]
237+
: []),
206238
] as { id: string; title: string }[];
207239

208240
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://ui.vllnt.com";
@@ -224,6 +256,7 @@ export default async function ComponentPage(props: Props) {
224256
url: `${SITE_URL}/components/${component.name}`,
225257
},
226258
]),
259+
...(componentSeo?.faqs.length ? [faqPageLd(componentSeo.faqs)] : []),
227260
])}
228261
/>
229262
<Sidebar
@@ -299,6 +332,30 @@ export default async function ComponentPage(props: Props) {
299332
</div>
300333
) : null}
301334

335+
{/* Usage block for non-AI components */}
336+
{!aiSeo?.whenToUse && componentSeo?.whatItIs ? (
337+
<div className="mb-8 rounded-lg border border-border bg-muted/30 p-6">
338+
<h2 className="text-sm font-medium uppercase tracking-wide text-muted-foreground">
339+
What it is &amp; when to use
340+
</h2>
341+
<p className="mt-3 text-base leading-relaxed">
342+
{componentSeo.whatItIs}
343+
</p>
344+
{familyGroup ? (
345+
<Link
346+
className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-foreground underline"
347+
href={localizePathname(
348+
familyPath(familyGroup.category),
349+
locale,
350+
)}
351+
>
352+
Browse all {familyGroup.label}
353+
<ExternalLink className="size-3" />
354+
</Link>
355+
) : null}
356+
</div>
357+
) : null}
358+
302359
{/* Preview + Playground */}
303360
{meta?.defaultStoryId ? (
304361
<PreviewPlaygroundTabs
@@ -393,6 +450,48 @@ export default async function ComponentPage(props: Props) {
393450
</div>
394451
) : null}
395452

453+
{/* FAQ (visible content backing FAQPage structured data) */}
454+
{componentSeo?.faqs.length ? (
455+
<div className="mb-8 scroll-mt-8" id="faq">
456+
<h2 className="text-2xl font-semibold mb-4">
457+
Frequently asked questions
458+
</h2>
459+
<dl className="space-y-6">
460+
{componentSeo.faqs.map((faq) => (
461+
<div key={faq.question}>
462+
<dt className="font-medium">{faq.question}</dt>
463+
<dd className="mt-2 leading-relaxed text-muted-foreground">
464+
{faq.answer}
465+
</dd>
466+
</div>
467+
))}
468+
</dl>
469+
</div>
470+
) : null}
471+
472+
{/* Related components (internal linking) */}
473+
{relatedComponents.length > 0 ? (
474+
<div className="mb-8 scroll-mt-8" id="related">
475+
<h2 className="text-2xl font-semibold mb-4">
476+
Related components
477+
</h2>
478+
<div className="flex flex-wrap gap-2">
479+
{relatedComponents.map((related) => (
480+
<Link
481+
className="rounded-md border px-3 py-1 text-sm transition-colors hover:bg-muted"
482+
href={localizePathname(
483+
`/components/${related.name}`,
484+
locale,
485+
)}
486+
key={related.name}
487+
>
488+
{related.title}
489+
</Link>
490+
))}
491+
</div>
492+
</div>
493+
) : null}
494+
396495
<ShareSection
397496
shareOn="Share on"
398497
shareTitle="Share this component"

0 commit comments

Comments
 (0)