← 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
A apps/web/app/cast/[slug]/page.tsxM apps/web/app/cast/page.tsxA apps/web/app/episodes/[season]/[episode]/page.tsxM apps/web/app/episodes/page.tsxM apps/web/app/page.tsxA apps/web/app/robots.tsA apps/web/app/sitemap.tsM packages/core/src/index.tsA packages/core/src/slug.ts
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 →