diff --git a/.env.example b/.env.example
index f85b346b..e3a6fba3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,5 @@
+NEXT_PUBLIC_SITE_URL=https://prabhupadasays.org
+
# BFF API Server (server-side fetches)
BFF_URL=http://localhost:8080
diff --git a/app/(app)/books/bbd/[chapter]/page.tsx b/app/(app)/books/bbd/[chapter]/page.tsx
index 6aff09c3..b3bf9ca2 100644
--- a/app/(app)/books/bbd/[chapter]/page.tsx
+++ b/app/(app)/books/bbd/[chapter]/page.tsx
@@ -13,6 +13,8 @@ import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/layout/page-header";
+import { JsonLd } from "@/components/seo/json-ld";
+import { articleJsonLd } from "@/lib/jsonld";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -71,6 +73,7 @@ export default async function BbdChapterPage({
return (
+
+
+
+
+
+
+
+
+
+ {/* Structured data for the currently-shown verse (SSR'd for the initial
+ verse, updates on client navigation). */}
+ {currentVerse ? (
+
+ ) : null}
+
{/* ---- Sidebar: Verse Index ---- */}
{/* Sticky sidebar. The max-h leaves 76px at the viewport bottom for
the site footer so the sidebar doesn't slide behind it when the
diff --git a/app/(app)/books/ds/[chapter]/page.tsx b/app/(app)/books/ds/[chapter]/page.tsx
index 5904783c..23fa7d14 100644
--- a/app/(app)/books/ds/[chapter]/page.tsx
+++ b/app/(app)/books/ds/[chapter]/page.tsx
@@ -13,6 +13,8 @@ import { getProseChapter } from "@/lib/api";
import { ogForChapter, extractQuoteFromHl, buildOgMeta } from "@/lib/og";
import { BOOK_NAMES } from "@/lib/constants";
import { PageHeader } from "@/components/layout/page-header";
+import { JsonLd } from "@/components/seo/json-ld";
+import { articleJsonLd } from "@/lib/jsonld";
import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -77,6 +79,7 @@ export default async function DsChapterPage({
return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
) get a rich OG card
* pointing at /api/og/profile/. The layout and the body
* below are client components; only this file is server.
- * 2. Mount the client tree (), which reads the
- * ProfileContext provided by the parent layout for the post-grid
- * hrefs and renders the feed.
+ * 2. Emit server-rendered ProfilePage/Person JSON-LD so crawlers + LLMs
+ * can resolve the profile (the layout is a client component and
+ * fetches the profile after hydration, too late for crawlers).
+ * 3. Mount the client tree (), which reads the
+ * ProfileContext provided by the parent layout.
*/
import type { Metadata } from "next";
+import { bff } from "@/lib/bff";
+import { JsonLd } from "@/components/seo/json-ld";
+import { profileJsonLd } from "@/lib/jsonld";
import { PostsGrid } from "./posts-grid";
import { metadataForHandle } from "./profile-og";
@@ -23,6 +28,32 @@ export async function generateMetadata({ params }: Props): Promise {
return metadataForHandle(handle);
}
-export default function PublicProfilePage({ params }: Props) {
- return ;
+export default async function PublicProfilePage({ params }: Props) {
+ const { handle } = await params;
+
+ let person: object | null = null;
+ try {
+ const profile = await bff<{
+ handle: string;
+ name: string;
+ avatar_url?: string | null;
+ }>(`/u/${encodeURIComponent(handle)}`, { revalidate: 60 });
+ if (profile) {
+ person = profileJsonLd({
+ path: `/u/${profile.handle}`,
+ name: profile.name,
+ handle: profile.handle,
+ image: profile.avatar_url ?? null,
+ });
+ }
+ } catch {
+ // Profile unavailable — skip JSON-LD rather than fail the page.
+ }
+
+ return (
+ <>
+ {person ? : null}
+
+ >
+ );
}
diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts
new file mode 100644
index 00000000..ea5c72c8
--- /dev/null
+++ b/app/api/revalidate/route.ts
@@ -0,0 +1,28 @@
+import { revalidateTag } from "next/cache";
+import type { NextRequest } from "next/server";
+
+/**
+ * On-demand cache revalidation. The BFF pings this (fire-and-forget) when
+ * sitemap-relevant data changes — a new signup, or a content re-seed/reindex —
+ * so /sitemap.xml refreshes within seconds instead of waiting out the 24h ISR
+ * window. Secret-gated; lives under /api/ which robots.txt disallows.
+ *
+ * POST /api/revalidate?tag=sitemap&secret=…
+ */
+export const runtime = "nodejs";
+
+export async function POST(req: NextRequest) {
+ const expected = process.env.REVALIDATE_SECRET;
+ const secret = req.nextUrl.searchParams.get("secret");
+ if (!expected || secret !== expected) {
+ return Response.json(
+ { revalidated: false, error: "unauthorized" },
+ { status: 401 },
+ );
+ }
+ const tag = req.nextUrl.searchParams.get("tag") || "sitemap";
+ // Next 16 requires a cache-life profile as the 2nd arg; "max" fully purges
+ // the tagged entries so the next request re-fetches from the BFF.
+ revalidateTag(tag, "max");
+ return Response.json({ revalidated: true, tag });
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index dc7cfa4f..66f3723e 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -43,6 +43,12 @@ const bricolage = Bricolage_Grotesque({
});
export const metadata: Metadata = {
+ // Resolves all relative OG/Twitter image URLs and canonical links to
+ // absolute URLs (required for correct social previews + SEO; Next warns
+ // without it).
+ metadataBase: new URL(
+ process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org",
+ ),
title: "Prabhupāda Says",
description:
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
diff --git a/app/robots.ts b/app/robots.ts
new file mode 100644
index 00000000..cc5a0dae
--- /dev/null
+++ b/app/robots.ts
@@ -0,0 +1,45 @@
+import type { MetadataRoute } from "next";
+
+const SITE_URL =
+ process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org";
+
+/**
+ * robots.txt (generated).
+ *
+ * Open the whole site to crawlers — search engines AND AI/LLM crawlers
+ * (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, etc. all honour the
+ * `*` rule, and we deliberately don't block them so the corpus is
+ * discoverable). Only private / non-content surfaces are disallowed:
+ *
+ * • /api/ — JSON endpoints, never a destination page
+ * • /settings — auth-only account settings
+ * • /notifications — auth-only
+ * • /verify-email — transactional
+ * • /chat — interactive tool, not indexable content
+ * • /u/me, /u/me/* — the signed-in user's own private library
+ * ($ anchors the bare path so public handles like
+ * /u/meera aren't caught by the prefix)
+ *
+ * Public content (books, verses, prose, letters, transcriptions, essays,
+ * disciples, magazines, journals, glossary, public profiles, collections)
+ * stays crawlable and is enumerated in the sitemap index.
+ */
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: [
+ "/api/",
+ "/settings",
+ "/notifications",
+ "/verify-email",
+ "/chat",
+ "/u/me$",
+ "/u/me/",
+ ],
+ },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ };
+}
diff --git a/app/sitemap.ts b/app/sitemap.ts
new file mode 100644
index 00000000..21a03945
--- /dev/null
+++ b/app/sitemap.ts
@@ -0,0 +1,97 @@
+import type { MetadataRoute } from "next";
+import { bff } from "@/lib/bff";
+
+/**
+ * Sitemap (index + sharded children) for the whole public corpus.
+ *
+ * How this stays cheap on the BFF:
+ * • One projection-only BFF call (`/sitemap/content`) returns every content
+ * leaf path in a single sweep — no per-document fan-out.
+ * • That fetch is cached for 24h (revalidate below) AND the rendered XML is
+ * ISR-cached, so crawlers hit Next's cache, not the BFF. The BFF sees
+ * ~one query/day regardless of crawl volume (tag "sitemap" allows
+ * on-demand revalidation after a re-seed).
+ * • generateSitemaps() shards into ≤40k-URL files (under the 50k/50MB
+ * limits) and Next serves a sitemap index at /sitemap.xml automatically.
+ */
+
+export const revalidate = 86400; // 24h
+
+const SITE_URL =
+ process.env.NEXT_PUBLIC_SITE_URL || "https://prabhupadasays.org";
+const PER_SITEMAP = 40000;
+
+// Top-level hubs that aren't content leaves but should be indexed.
+const STATIC_PATHS = [
+ "/",
+ "/about",
+ "/books",
+ "/letters",
+ "/transcriptions",
+ "/disciples",
+ "/glossary",
+ "/essays",
+ "/magazines",
+ "/journals",
+ "/other-documents",
+];
+
+/**
+ * All indexable paths: static hubs + book/division landing pages (derived
+ * from the content leaves) + every content leaf from the BFF. Resilient — if
+ * the BFF call fails we still emit the static hubs so /sitemap.xml never 500s.
+ */
+async function fetchUrls(path: string): Promise {
+ try {
+ const res = await bff<{ urls: string[] }>(path, {
+ revalidate,
+ tags: ["sitemap"],
+ });
+ return res?.urls ?? [];
+ } catch {
+ return [];
+ }
+}
+
+async function allPaths(): Promise {
+ // Content (Mongo) and public profiles (Postgres) come from separate
+ // projection-only endpoints. New signups appear automatically on the next
+ // daily revalidation.
+ const [content, users] = await Promise.all([
+ fetchUrls("/sitemap/content"),
+ fetchUrls("/sitemap/users"),
+ ]);
+
+ // Derive non-redirecting hub pages from the content leaves: every book's
+ // landing page, and the SB canto / CC līlā division pages.
+ const hubs = new Set();
+ for (const p of content) {
+ const book = p.match(/^\/books\/([^/]+)/);
+ if (book) hubs.add(`/books/${book[1]}`);
+ const division = p.match(/^\/books\/(sb|cc)\/([^/]+)\/\d+\/\d+$/);
+ if (division) hubs.add(`/books/${division[1]}/${division[2]}`);
+ }
+
+ // Dedupe across static + hubs + content + profiles.
+ return [...new Set([...STATIC_PATHS, ...hubs, ...content, ...users])];
+}
+
+export async function generateSitemaps(): Promise<{ id: number }[]> {
+ const paths = await allPaths();
+ const count = Math.max(1, Math.ceil(paths.length / PER_SITEMAP));
+ return Array.from({ length: count }, (_, id) => ({ id }));
+}
+
+export default async function sitemap({
+ id,
+}: {
+ id: number;
+}): Promise {
+ const paths = await allPaths();
+ const slice = paths.slice(id * PER_SITEMAP, (id + 1) * PER_SITEMAP);
+ return slice.map((path) => ({
+ url: `${SITE_URL}${path}`,
+ changeFrequency: "weekly",
+ priority: path === "/" ? 1 : STATIC_PATHS.includes(path) ? 0.8 : 0.7,
+ }));
+}
diff --git a/components/seo/json-ld.tsx b/components/seo/json-ld.tsx
new file mode 100644
index 00000000..a09c6079
--- /dev/null
+++ b/components/seo/json-ld.tsx
@@ -0,0 +1,14 @@
+/**
+ * Renders a schema.org JSON-LD