seo: dynamic sitemap + robots + per-page metadata + JSON-LD

Brings the site up to enterprise SEO standards. Google now gets a complete
machine-readable map of the content, with multilingual hreflang tags,
structured data for the knowledge panel, and rich Open Graph cards on
LinkedIn / WhatsApp / Twitter.

NEW
- src/app/sitemap.ts: dynamic sitemap.xml from Prisma. Emits 5 locales x
  every active application + every active news article, with hreflang
  alternates linking each translation. Hourly revalidation.
- src/app/robots.ts: robots.txt blocks /hq-command/, /api/, /parts (B2B
  auth-gated), points crawlers at the sitemap.
- src/lib/seo.ts: helpers for canonical URLs, hreflang alternates, and
  JSON-LD schemas (Organization, WebSite, Article, Product, BreadcrumbList).
- src/components/seo/JsonLd.tsx: server component that emits one
  application/ld+json script tag per page.

PER-PAGE generateMetadata
- Home: localized titles + descriptions in EN/IT/VEC/ES/DE
- News hub: title built from translations, hreflang tags
- News article: title/description from DB, OG image = cover, type=article,
  publishedTime + modifiedTime for date freshness signals
- Applications: title/description from DB, type=product, hero image
- Heritage: localized title/description

JSON-LD STRUCTURED DATA
- Site-wide (in root layout): Organization (with HQ address, founder,
  contact, social profiles) + WebSite — drives Google knowledge panel
- Article pages: Article schema with publisher/datePublished/dateModified
  — required for Google News / Discover eligibility
- Application pages: Product schema (FLUX brand, RF Industrial category)
  + BreadcrumbList — drives rich-snippet breadcrumb in search results

NOTES
- Open Graph metadataBase set from NEXT_PUBLIC_APP_URL so absolute URLs
  for OG images are correct (LinkedIn previews require absolute paths)
- All pages have canonical URLs to prevent duplicate-content penalties
- /parts already has noindex meta (B2B portal) — also blocked in robots
- No DB schema changes. Pure additions to /src/lib and /src/app.
This commit is contained in:
2026-05-04 14:42:43 -05:00
parent f931ae281c
commit 09e6d0c7cf
10 changed files with 574 additions and 13 deletions
+104
View File
@@ -0,0 +1,104 @@
// src/app/sitemap.ts
// ─────────────────────────────────────────────────────────────────────────────
// Dynamic sitemap generated from Prisma data — emits one entry per locale per
// active page (home, applications, news articles, heritage, news hub).
//
// Auto-discoverable at /sitemap.xml, no Nginx config needed.
// Search engines re-crawl this on each visit; Next.js caches it for `revalidate`.
// ─────────────────────────────────────────────────────────────────────────────
import type { MetadataRoute } from "next";
import { prisma } from "@/lib/prisma";
const LOCALES = ["en", "it", "vec", "es", "de"] as const;
function baseUrl() {
return (process.env.NEXT_PUBLIC_APP_URL || "https://rf-flux.com").replace(/\/$/, "");
}
export const revalidate = 3600; // Re-generate sitemap once per hour
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = baseUrl();
const now = new Date();
const entries: MetadataRoute.Sitemap = [];
// ── Static routes ─────────────────────────────────────────────
const staticPaths = [
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
{ path: "/news", priority: 0.7, changeFrequency: "daily" as const },
{ path: "/heritage", priority: 0.6, changeFrequency: "monthly" as const },
];
for (const locale of LOCALES) {
for (const { path, priority, changeFrequency } of staticPaths) {
entries.push({
url: `${base}/${locale}${path}`,
lastModified: now,
changeFrequency,
priority,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}${path}`])
),
},
});
}
}
// ── Application pages ─────────────────────────────────────────
try {
const applications = await prisma.application.findMany({
where: { isActive: true },
select: { slug: true, updatedAt: true },
});
for (const app of applications) {
for (const locale of LOCALES) {
entries.push({
url: `${base}/${locale}/applications/${app.slug}`,
lastModified: app.updatedAt,
changeFrequency: "weekly",
priority: 0.9,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}/applications/${app.slug}`])
),
},
});
}
}
} catch (error) {
console.error("[sitemap] Failed to load applications:", error);
}
// ── News articles ─────────────────────────────────────────────
try {
const articles = await prisma.newsArticle.findMany({
where: { isActive: true },
select: { slug: true, updatedAt: true, publishedAt: true },
orderBy: { publishedAt: "desc" },
});
for (const article of articles) {
for (const locale of LOCALES) {
entries.push({
url: `${base}/${locale}/news/${article.slug}`,
lastModified: article.updatedAt,
changeFrequency: "monthly",
priority: 0.7,
alternates: {
languages: Object.fromEntries(
LOCALES.map((alt) => [alt, `${base}/${alt}/news/${article.slug}`])
),
},
});
}
}
} catch (error) {
console.error("[sitemap] Failed to load news articles:", error);
}
return entries;
}