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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user