[object Object]

← back to Nineoh Guide

SEO layer: crawlable per-episode + per-cast detail pages w/ JSON-LD (TVEpisode/Person/TVSeries), sitemap.xml (135 urls), robots.txt, per-page metadata; list pages link into details

e3f69d6c2fa68a0c6b747fe973ac859a4240345d · 2026-07-25 11:04:34 -0700 · Steve

Files touched

Diff

commit e3f69d6c2fa68a0c6b747fe973ac859a4240345d
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 25 11:04:34 2026 -0700

    SEO layer: crawlable per-episode + per-cast detail pages w/ JSON-LD (TVEpisode/Person/TVSeries), sitemap.xml (135 urls), robots.txt, per-page metadata; list pages link into details
---
 apps/web/app/cast/[slug]/page.tsx                 |  84 ++++++++++++++++++
 apps/web/app/cast/page.tsx                        |   5 +-
 apps/web/app/episodes/[season]/[episode]/page.tsx | 101 ++++++++++++++++++++++
 apps/web/app/episodes/page.tsx                    |   2 +-
 apps/web/app/page.tsx                             |  13 +++
 apps/web/app/robots.ts                            |   9 ++
 apps/web/app/sitemap.ts                           |  38 ++++++++
 packages/core/src/index.ts                        |   6 ++
 packages/core/src/slug.ts                         |  15 ++++
 9 files changed, 271 insertions(+), 2 deletions(-)

diff --git a/apps/web/app/cast/[slug]/page.tsx b/apps/web/app/cast/[slug]/page.tsx
new file mode 100644
index 0000000..0709c83
--- /dev/null
+++ b/apps/web/app/cast/[slug]/page.tsx
@@ -0,0 +1,84 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { pool } from "@/lib/db";
+import { SITE_URL, slugify, castPath } from "@nineoh/core";
+
+export const dynamic = "force-dynamic";
+
+type Params = { slug: string };
+
+async function getPersonBySlug(slug: string) {
+  try {
+    const { rows } = await pool.query(
+      `select cp.name, cp.biography_original, cp.bio_status, ch.name as character_name
+         from cast_people cp
+         left join credits cr on cr.person_id = cp.id and cr.credit_type='main-cast'
+         left join characters ch on ch.id = cr.character_id`
+    );
+    return rows.find((r) => slugify(r.name) === slug) ?? null;
+  } catch {
+    return null;
+  }
+}
+
+export async function generateMetadata({
+  params,
+}: {
+  params: Promise<Params>;
+}): Promise<Metadata> {
+  const { slug } = await params;
+  const p = await getPersonBySlug(slug);
+  if (!p) return { title: "Cast member not found" };
+  const t = p.character_name ? `${p.name} — ${p.character_name}` : p.name;
+  const desc = p.biography_original ?? `${p.name} in the 90210 cast.`;
+  return {
+    title: t,
+    description: desc,
+    alternates: { canonical: `${SITE_URL}${castPath(p.name)}` },
+    openGraph: { title: t, description: desc, type: "profile" },
+  };
+}
+
+export default async function CastMember({
+  params,
+}: {
+  params: Promise<Params>;
+}) {
+  const { slug } = await params;
+  const p = await getPersonBySlug(slug);
+  if (!p) notFound();
+
+  const jsonLd = {
+    "@context": "https://schema.org",
+    "@type": "Person",
+    name: p.name,
+    ...(p.biography_original ? { description: p.biography_original } : {}),
+    ...(p.character_name
+      ? { performerIn: { "@type": "TVSeries", name: "90210 (2008)" } }
+      : {}),
+  };
+
+  return (
+    <article style={{ lineHeight: 1.7 }}>
+      <script
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+      />
+      <p style={{ fontSize: 12, color: "#999" }}>
+        <a href="/cast">← Cast</a>
+      </p>
+      <h1 style={{ marginBottom: 2 }}>{p.name}</h1>
+      {p.character_name ? (
+        <p style={{ color: "#8a1c1c", marginTop: 0, fontWeight: 600 }}>
+          as {p.character_name}
+        </p>
+      ) : null}
+      <p style={{ fontSize: 15 }}>
+        {p.biography_original ?? "An original bio for this cast member is coming soon."}
+      </p>
+      {p.bio_status === "ai-draft" ? (
+        <p style={{ fontSize: 11, color: "#aaa" }}>Editorial bio draft — pending review.</p>
+      ) : null}
+    </article>
+  );
+}
diff --git a/apps/web/app/cast/page.tsx b/apps/web/app/cast/page.tsx
index 9df72b6..afb8df1 100644
--- a/apps/web/app/cast/page.tsx
+++ b/apps/web/app/cast/page.tsx
@@ -1,5 +1,6 @@
 import { pool } from "@/lib/db";
 import { RightsImage } from "@/components/RightsImage";
+import { castPath } from "@nineoh/core";
 
 export const dynamic = "force-dynamic";
 export const metadata = { title: "Cast" };
@@ -36,7 +37,9 @@ export default async function Cast() {
             <div key={p.id} style={{ display: "flex", gap: 12 }}>
               <RightsImage asset={null} alt={p.name} width={90} height={90} />
               <div>
-                <strong>{p.name}</strong>
+                <strong>
+                  <a href={castPath(p.name)}>{p.name}</a>
+                </strong>
                 {p.character_name ? (
                   <span style={{ color: "#8a1c1c", fontSize: 13 }}>
                     {" "}as {p.character_name}
diff --git a/apps/web/app/episodes/[season]/[episode]/page.tsx b/apps/web/app/episodes/[season]/[episode]/page.tsx
new file mode 100644
index 0000000..5b2d8b9
--- /dev/null
+++ b/apps/web/app/episodes/[season]/[episode]/page.tsx
@@ -0,0 +1,101 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { pool } from "@/lib/db";
+import { SITE_URL, episodePath } from "@nineoh/core";
+
+export const dynamic = "force-dynamic";
+
+type Params = { season: string; episode: string };
+
+async function getEpisode(season: number, episode: number) {
+  try {
+    const { rows } = await pool.query(
+      `select season_number, episode_number, title, air_date, writer, director,
+              summary_short_original, summary_full_original, recap_status
+         from episodes where season_number=$1 and episode_number=$2 limit 1`,
+      [season, episode]
+    );
+    return rows[0] ?? null;
+  } catch {
+    return null;
+  }
+}
+
+export async function generateMetadata({
+  params,
+}: {
+  params: Promise<Params>;
+}): Promise<Metadata> {
+  const { season, episode } = await params;
+  const ep = await getEpisode(Number(season), Number(episode));
+  if (!ep) return { title: "Episode not found" };
+  const t = `90210 S${ep.season_number}E${ep.episode_number}: ${ep.title}`;
+  const desc =
+    ep.summary_short_original ??
+    `Episode guide for 90210 Season ${ep.season_number}, Episode ${ep.episode_number}: ${ep.title}.`;
+  return {
+    title: t,
+    description: desc,
+    alternates: { canonical: `${SITE_URL}${episodePath(ep.season_number, ep.episode_number)}` },
+    openGraph: { title: t, description: desc, type: "article" },
+  };
+}
+
+export default async function EpisodePage({
+  params,
+}: {
+  params: Promise<Params>;
+}) {
+  const { season, episode } = await params;
+  const ep = await getEpisode(Number(season), Number(episode));
+  if (!ep) notFound();
+
+  const jsonLd = {
+    "@context": "https://schema.org",
+    "@type": "TVEpisode",
+    name: ep.title,
+    episodeNumber: ep.episode_number,
+    partOfSeason: { "@type": "TVSeason", seasonNumber: ep.season_number },
+    partOfSeries: { "@type": "TVSeries", name: "90210 (2008)" },
+    ...(ep.air_date ? { datePublished: new Date(ep.air_date).toISOString().slice(0, 10) } : {}),
+    ...(ep.summary_short_original ? { description: ep.summary_short_original } : {}),
+  };
+
+  return (
+    <article style={{ lineHeight: 1.7 }}>
+      <script
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+      />
+      <p style={{ fontSize: 12, color: "#999" }}>
+        <a href="/episodes">← Episode Guide</a>
+      </p>
+      <h1 style={{ marginBottom: 4 }}>
+        <span style={{ color: "#8a1c1c" }}>
+          S{ep.season_number}E{ep.episode_number}
+        </span>{" "}
+        · {ep.title}
+      </h1>
+      <p style={{ color: "#999", fontSize: 13, marginTop: 0 }}>
+        {ep.air_date
+          ? `First aired ${new Date(ep.air_date).toLocaleDateString()}`
+          : "Air date TBD"}
+        {ep.writer ? ` · Written by ${ep.writer}` : ""}
+        {ep.director ? ` · Directed by ${ep.director}` : ""}
+      </p>
+
+      {ep.summary_short_original ? (
+        <>
+          <p style={{ fontSize: 16 }}>{ep.summary_short_original}</p>
+          {ep.recap_status === "ai-draft" ? (
+            <p style={{ fontSize: 11, color: "#aaa" }}>
+              Editorial recap draft — pending review.
+            </p>
+          ) : null}
+        </>
+      ) : (
+        <p style={{ color: "#aaa" }}>An original recap for this episode is coming soon.</p>
+      )}
+    </article>
+  );
+}
diff --git a/apps/web/app/episodes/page.tsx b/apps/web/app/episodes/page.tsx
index 365bb1a..2b2b951 100644
--- a/apps/web/app/episodes/page.tsx
+++ b/apps/web/app/episodes/page.tsx
@@ -50,7 +50,7 @@ export default async function Episodes() {
                   S{s}E{ep.episode_number}
                 </span>
                 <span style={{ flex: 1 }}>
-                  {ep.title}
+                  <a href={`/episodes/${s}/${ep.episode_number}`}>{ep.title}</a>
                   {ep.summary_short_original ? (
                     <span style={{ display: "block", color: "#555", fontSize: 13 }}>
                       {ep.summary_short_original}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 1978cc2..d1fb4e9 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -21,8 +21,21 @@ async function getStats() {
 
 export default async function Home() {
   const { show, episodes, cast } = await getStats();
+  const jsonLd = {
+    "@context": "https://schema.org",
+    "@type": "TVSeries",
+    name: "90210 (2008)",
+    description:
+      "An unofficial fan guide to the 2008 teen drama 90210 — original episode recaps and cast profiles.",
+    numberOfSeasons: 5,
+    numberOfEpisodes: episodes,
+  };
   return (
     <section>
+      <script
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+      />
       <h1 style={{ fontSize: 30, marginBottom: 4 }}>
         {show?.display_title ?? "Unofficial 90210 Guide"}
       </h1>
diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts
new file mode 100644
index 0000000..82191dd
--- /dev/null
+++ b/apps/web/app/robots.ts
@@ -0,0 +1,9 @@
+import type { MetadataRoute } from "next";
+import { SITE_URL } from "@nineoh/core";
+
+export default function robots(): MetadataRoute.Robots {
+  return {
+    rules: { userAgent: "*", allow: "/" },
+    sitemap: `${SITE_URL}/sitemap.xml`,
+  };
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
new file mode 100644
index 0000000..272d019
--- /dev/null
+++ b/apps/web/app/sitemap.ts
@@ -0,0 +1,38 @@
+import type { MetadataRoute } from "next";
+import { pool } from "@/lib/db";
+import { SITE_URL, episodePath, castPath } from "@nineoh/core";
+
+export const dynamic = "force-dynamic";
+
+export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+  const urls: MetadataRoute.Sitemap = [
+    "",
+    "/episodes",
+    "/cast",
+    "/news",
+    "/legal/disclaimer",
+    "/legal/dmca",
+    "/legal/privacy",
+  ].map((p) => ({ url: `${SITE_URL}${p}`, changeFrequency: "weekly", priority: p === "" ? 1 : 0.6 }));
+
+  try {
+    const eps = await pool.query(
+      `select season_number, episode_number from episodes order by season_number, episode_number`
+    );
+    for (const e of eps.rows) {
+      urls.push({
+        url: `${SITE_URL}${episodePath(e.season_number, e.episode_number)}`,
+        changeFrequency: "monthly",
+        priority: 0.7,
+      });
+    }
+    const cast = await pool.query(`select name from cast_people order by name`);
+    for (const c of cast.rows) {
+      urls.push({ url: `${SITE_URL}${castPath(c.name)}`, changeFrequency: "monthly", priority: 0.5 });
+    }
+  } catch {
+    /* DB unreachable at build — static routes still returned */
+  }
+
+  return urls;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index da27c7c..7db7ac9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,4 +1,10 @@
 export * from "./schemas";
+export * from "./slug";
+
+/** Canonical public site origin — used in JSON-LD + sitemap. Set via env in prod. */
+export const SITE_URL =
+  (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_SITE_URL) ||
+  "https://unofficial-90210-guide.example";
 
 /** App-wide constants shared by web + mobile. */
 export const APP = {
diff --git a/packages/core/src/slug.ts b/packages/core/src/slug.ts
new file mode 100644
index 0000000..d5ce225
--- /dev/null
+++ b/packages/core/src/slug.ts
@@ -0,0 +1,15 @@
+/** Deterministic slug helpers shared by web routes + sitemap. */
+export function slugify(s: string): string {
+  return s
+    .toLowerCase()
+    .normalize("NFKD")
+    .replace(/[̀-ͯ]/g, "") // strip accents
+    .replace(/['’.]/g, "")
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-+|-+$/g, "");
+}
+
+export const episodePath = (season: number, episode: number) =>
+  `/episodes/${season}/${episode}`;
+
+export const castPath = (name: string) => `/cast/${slugify(name)}`;

← 9cde287 cast bios: 14 original ai-draft bios + person->character mai  ·  back to Nineoh Guide  ·  contrarian fixes: add recap_status/bio_status to canonical s 5d109ee →