SEO: add JSON-LD structured data

- Add generic JSON-LD injector component and builders
  - components/seo/JsonLd.tsx
  - lib/seo/jsonld.ts
- Inject Organization and WebSite JSON-LD globally in [lang]/layout
- Inject WebApplication JSON-LD on the localized home page
  - Localize description/url/inLanguage and set alternateName ["PrivyDrop", "PrivyDrop APP"]
- Inject FAQPage JSON-LD only on /[lang]/faq (not on home)
  - Build Q&A from messages.text.faqs
- Inject BlogPosting + BreadcrumbList on blog post pages
  - Use frontmatter.cover as image, localized breadcrumbs

Notes
- Use stable @id anchors (/#organization, /#website, /[lang]#app, /[lang]/blog/[slug]#post)
- Respect multi-language setup across en/zh/ja/es/de/fr/ko
- SameAs limited to GitHub and X as provided
- Site URL resolved via NEXT_PUBLIC_SITE_URL or defaults to https://www.privydrop.app
This commit is contained in:
david_bai
2025-10-13 21:19:07 +08:00
parent 99f264fcd0
commit 0621fb27db
6 changed files with 256 additions and 2 deletions
+32
View File
@@ -6,6 +6,14 @@ import { mdxOptions } from "@/lib/mdx-config";
import { mdxComponents } from "@/components/blog/MDXComponents";
import { TableOfContents } from "@/components/blog/TableOfContents";
import { generateMetadata } from "./metadata";
import JsonLd from "@/components/seo/JsonLd";
import {
absoluteUrl,
buildBlogPostingJsonLd,
buildBreadcrumbJsonLd,
getSiteUrl,
} from "@/lib/seo/jsonld";
import { getDictionary } from "@/lib/dictionary";
export { generateMetadata };
@@ -15,13 +23,37 @@ export default async function BlogPost({
params: { slug: string; lang: string };
}) {
const post = await getPostBySlug(params.slug, params.lang);
const messages = await getDictionary(params.lang);
if (!post) {
return <div>Post not found</div>;
}
const siteUrl = getSiteUrl();
const postUrl = `${siteUrl}/${params.lang}/blog/${params.slug}`;
const imageUrl = absoluteUrl(post.frontmatter.cover, siteUrl);
const postLd = buildBlogPostingJsonLd({
siteUrl,
url: postUrl,
title: post.frontmatter.title,
description: post.frontmatter.description,
datePublished: post.frontmatter.date,
dateModified: post.frontmatter.date,
authorName: post.frontmatter.author,
imageUrl,
inLanguage: params.lang,
});
const breadcrumbsLd = buildBreadcrumbJsonLd({
items: [
{ name: messages.text.Header.Home_dis, item: `${siteUrl}/${params.lang}` },
{ name: messages.text.Header.Blog_dis, item: `${siteUrl}/${params.lang}/blog` },
{ name: post.frontmatter.title, item: postUrl },
],
});
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
<JsonLd id="post-ld" data={[postLd, breadcrumbsLd]} />
{/* Use md: prefix to handle flex layout for medium screens and above */}
<div className="block md:flex md:gap-8">
{/* Article content area */}
+23 -1
View File
@@ -2,6 +2,8 @@ import FAQSection from "@/components/web/FAQSection";
import type { Metadata } from "next";
import { getDictionary } from "@/lib/dictionary";
import { supportedLocales } from "@/constants/i18n-config";
import JsonLd from "@/components/seo/JsonLd";
import { buildFaqJsonLd } from "@/lib/seo/jsonld";
export async function generateMetadata({
params,
@@ -38,5 +40,25 @@ export default async function FAQ({
params: { lang: string };
}) {
const messages = await getDictionary(lang);
return <FAQSection messages={messages} />;
const faqsData = (messages as any).text.faqs as Record<string, string>;
const questionKeys = Object.keys(faqsData).filter((k) => k.startsWith("question_"));
const faqs = questionKeys
.map((qKey) => {
const idx = qKey.split("_")[1];
const aKey = `answer_${idx}`;
const q = faqsData[qKey];
const a = faqsData[aKey];
if (q && a) return { question: q, answer: a };
return null;
})
.filter(Boolean) as { question: string; answer: string }[];
const faqLd = buildFaqJsonLd({ inLanguage: lang, faqs });
return (
<>
<JsonLd id="faq-ld" data={faqLd} />
<FAQSection messages={messages} />
</>
);
}
+23
View File
@@ -3,6 +3,13 @@ import Header from "@/components/web/Header";
import Footer from "@/components/web/Footer";
import { ThemeProvider } from "@/components/web/theme-provider";
import { getDictionary } from "@/lib/dictionary";
import JsonLd from "@/components/seo/JsonLd";
import {
absoluteUrl,
buildOrganizationJsonLd,
buildWebSiteJsonLd,
getSiteUrl,
} from "@/lib/seo/jsonld";
export default async function RootLayout({
children,
@@ -12,11 +19,27 @@ export default async function RootLayout({
params: { lang: string };
}>) {
const messages = await getDictionary(lang);
const siteUrl = getSiteUrl();
const logoUrl = absoluteUrl("/logo.png", siteUrl);
const orgJson = buildOrganizationJsonLd({
siteUrl,
logoUrl,
sameAs: [
"https://github.com/david-bai00/PrivyDrop",
"https://x.com/David_vision66",
],
});
const websiteJson = buildWebSiteJsonLd({
siteUrl,
name: "PrivyDrop",
inLanguage: lang,
});
return (
<html lang={lang} className="h-full" suppressHydrationWarning>
<head />
<body className="min-h-full flex flex-col">
<JsonLd id="global-ld" data={[orgJson, websiteJson]} />
<ThemeProvider
attribute="class"
defaultTheme="system"
+20 -1
View File
@@ -2,6 +2,8 @@ import HomeClient from "./HomeClient";
import { getDictionary } from "@/lib/dictionary";
import { Metadata } from "next";
import { supportedLocales } from "@/constants/i18n-config";
import JsonLd from "@/components/seo/JsonLd";
import { buildWebAppJsonLd, getSiteUrl, absoluteUrl } from "@/lib/seo/jsonld";
export async function generateMetadata({
params,
@@ -39,6 +41,23 @@ export default async function Home({
params: { lang: string };
}) {
const messages = await getDictionary(lang);
const siteUrl = getSiteUrl();
const webAppLd = buildWebAppJsonLd({
siteUrl,
path: `/${lang}`,
name: "PrivyDrop",
alternateName: ["PrivyDrop", "PrivyDrop APP"],
description: messages.meta.home.description,
inLanguage: lang,
imageUrl: absoluteUrl("/logo.png", siteUrl),
applicationCategory: "UtilityApplication",
operatingSystem: "Web Browser",
});
return <HomeClient messages={messages} lang={lang} />;
return (
<>
<JsonLd id="home-ld" data={webAppLd} />
<HomeClient messages={messages} lang={lang} />
</>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
type JsonLdProps = {
data: Record<string, any> | Record<string, any>[];
id?: string;
};
export default function JsonLd({ data, id }: JsonLdProps) {
const blocks = Array.isArray(data) ? data : [data];
return (
<>
{blocks.map((item, idx) => (
<script
key={id ? `${id}-${idx}` : idx}
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
/>
))}
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
export const getSiteUrl = (): string => {
return process.env.NEXT_PUBLIC_SITE_URL || "https://www.privydrop.app";
};
export const absoluteUrl = (path: string, siteUrl = getSiteUrl()): string => {
if (!path) return siteUrl;
if (/^https?:\/\//i.test(path)) return path;
return `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
};
export function buildOrganizationJsonLd(params: {
siteUrl?: string;
name?: string;
logoUrl: string;
sameAs?: string[];
}) {
const siteUrl = params.siteUrl || getSiteUrl();
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${siteUrl}/#organization`,
name: params.name || "PrivyDrop",
url: `${siteUrl}/`,
logo: params.logoUrl,
sameAs: params.sameAs || [],
};
}
export function buildWebSiteJsonLd(params: {
siteUrl?: string;
name?: string;
inLanguage?: string;
}) {
const siteUrl = params.siteUrl || getSiteUrl();
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: `${siteUrl}/`,
name: params.name || "PrivyDrop",
publisher: { "@id": `${siteUrl}/#organization` },
inLanguage: params.inLanguage,
};
}
export function buildWebAppJsonLd(params: {
siteUrl?: string;
path: string; // e.g. '/zh'
name: string;
description: string;
inLanguage?: string;
alternateName?: string[];
imageUrl?: string;
applicationCategory?: string; // default UtilityApplication
operatingSystem?: string; // default Web Browser
}) {
const siteUrl = params.siteUrl || getSiteUrl();
const url = absoluteUrl(params.path, siteUrl);
return {
"@context": "https://schema.org",
"@type": "WebApplication",
"@id": `${url}#app`,
name: params.name,
alternateName: params.alternateName?.length ? params.alternateName : undefined,
description: params.description,
applicationCategory: params.applicationCategory || "UtilityApplication",
operatingSystem: params.operatingSystem || "Web Browser",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
url,
image: params.imageUrl,
publisher: { "@id": `${siteUrl}/#organization` },
inLanguage: params.inLanguage,
};
}
export function buildFaqJsonLd(params: {
inLanguage?: string;
faqs: { question: string; answer: string }[];
}) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: params.faqs.map((f) => ({
"@type": "Question",
name: f.question,
acceptedAnswer: { "@type": "Answer", text: f.answer },
})),
inLanguage: params.inLanguage,
};
}
export function buildBlogPostingJsonLd(params: {
siteUrl?: string;
url: string; // absolute url
title: string;
description: string;
datePublished: string;
dateModified?: string;
authorName: string;
imageUrl?: string;
inLanguage?: string;
}) {
const siteUrl = params.siteUrl || getSiteUrl();
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": `${params.url}#post`,
headline: params.title,
description: params.description,
datePublished: params.datePublished,
dateModified: params.dateModified || params.datePublished,
author: { "@type": "Person", name: params.authorName },
publisher: { "@id": `${siteUrl}/#organization` },
mainEntityOfPage: params.url,
image: params.imageUrl,
inLanguage: params.inLanguage,
};
}
export function buildBreadcrumbJsonLd(params: {
items: { name: string; item: string }[]; // absolute urls
}) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: params.items.map((it, idx) => ({
"@type": "ListItem",
position: idx + 1,
name: it.name,
item: it.item,
})),
};
}