← back to Nineoh Guide
loop refine 4/n: character detail pages (/characters + /characters/[slug]) — profile, actor link, guest-cast appearances + series-regular fallback; nav + sitemap; ISR
c766b0ca0b748284b7a067cb35c89cddcc7933dd · 2026-07-26 20:03:01 -0700 · Steve
Files touched
A apps/web/app/characters/[slug]/page.tsxA apps/web/app/characters/page.tsxM apps/web/app/layout.tsxM apps/web/app/show/page.tsxM apps/web/app/sitemap.ts
Diff
commit c766b0ca0b748284b7a067cb35c89cddcc7933dd
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 26 20:03:01 2026 -0700
loop refine 4/n: character detail pages (/characters + /characters/[slug]) — profile, actor link, guest-cast appearances + series-regular fallback; nav + sitemap; ISR
---
apps/web/app/characters/[slug]/page.tsx | 92 +++++++++++++++++++++++++++++++++
apps/web/app/characters/page.tsx | 53 +++++++++++++++++++
apps/web/app/layout.tsx | 1 +
apps/web/app/show/page.tsx | 6 +--
apps/web/app/sitemap.ts | 9 +++-
5 files changed, 157 insertions(+), 4 deletions(-)
diff --git a/apps/web/app/characters/[slug]/page.tsx b/apps/web/app/characters/[slug]/page.tsx
new file mode 100644
index 0000000..087b0a8
--- /dev/null
+++ b/apps/web/app/characters/[slug]/page.tsx
@@ -0,0 +1,92 @@
+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 revalidate = 3600;
+type Params = { slug: string };
+
+async function getCharacter(slug: string) {
+ try {
+ const { rows } = await pool.query(
+ `select c.id, c.name, c.description_original, cp.name as actor
+ from characters c
+ left join credits cr on cr.character_id = c.id and cr.credit_type='main-cast'
+ left join cast_people cp on cp.id = cr.person_id`
+ );
+ const c = rows.find((r) => slugify(r.name) === slug);
+ if (!c) return null;
+ const eps = await pool.query(
+ `select e.season_number, e.episode_number, e.title
+ from episode_guest_cast g join episodes e on e.id = g.episode_id
+ where g.character_name = $1
+ order by e.season_number, e.episode_number`,
+ [c.name]
+ );
+ return { ...c, episodes: eps.rows };
+ } catch {
+ return null;
+ }
+}
+
+export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
+ const { slug } = await params;
+ const c = await getCharacter(slug);
+ if (!c) return { title: "Character not found" };
+ return {
+ title: `${c.name} — 90210 Character`,
+ description: c.description_original ?? `${c.name} on 90210.`,
+ alternates: { canonical: `${SITE_URL}/characters/${slug}` },
+ };
+}
+
+export default async function CharacterPage({ params }: { params: Promise<Params> }) {
+ const { slug } = await params;
+ const c = await getCharacter(slug);
+ if (!c) notFound();
+
+ return (
+ <article style={{ lineHeight: 1.7 }}>
+ <p style={{ fontSize: 12, color: "var(--muted)" }}>
+ <a href="/characters">← Characters</a>
+ </p>
+ <h1 style={{ marginBottom: 2 }}>{c.name}</h1>
+ {c.actor ? (
+ <p style={{ marginTop: 0, color: "var(--muted)", fontSize: 14 }}>
+ Played by{" "}
+ <a href={castPath(c.actor)} style={{ color: "var(--maroon)", fontWeight: 600 }}>
+ {c.actor}
+ </a>
+ </p>
+ ) : null}
+ <p style={{ fontSize: 15.5 }}>{c.description_original}</p>
+
+ {c.episodes.length === 0 && c.actor && (
+ <p style={{ color: "var(--muted)", fontSize: 13 }}>
+ A series regular — appears across the run of the show. (Per-episode
+ listings below cover guest & recurring appearances only.)
+ </p>
+ )}
+
+ {c.episodes.length > 0 && (
+ <>
+ <h2 style={{ fontSize: 17 }}>Appears in ({c.episodes.length})</h2>
+ <div
+ style={{
+ display: "grid",
+ gap: 6,
+ gridTemplateColumns: "repeat(auto-fill, minmax(210px, 1fr))",
+ fontSize: 13,
+ }}
+ >
+ {c.episodes.map((e: any) => (
+ <a key={`${e.season_number}-${e.episode_number}`} href={`/episodes/${e.season_number}/${e.episode_number}`}>
+ <span className="se">S{e.season_number}E{e.episode_number}</span> {e.title}
+ </a>
+ ))}
+ </div>
+ </>
+ )}
+ </article>
+ );
+}
diff --git a/apps/web/app/characters/page.tsx b/apps/web/app/characters/page.tsx
new file mode 100644
index 0000000..899ee34
--- /dev/null
+++ b/apps/web/app/characters/page.tsx
@@ -0,0 +1,53 @@
+import { pool } from "@/lib/db";
+import { slugify } from "@nineoh/core";
+
+export const revalidate = 3600;
+export const metadata = {
+ title: "Characters",
+ description: "Every main character on 90210 — profiles, who plays them, and the episodes they appear in.",
+};
+
+async function getCharacters() {
+ try {
+ const { rows } = await pool.query(
+ `select c.name, c.description_original, cp.name as actor,
+ (select count(*) from episode_guest_cast g where g.character_name = c.name)::int as appearances
+ from characters c
+ left join credits cr on cr.character_id = c.id and cr.credit_type='main-cast'
+ left join cast_people cp on cp.id = cr.person_id
+ where c.description_original is not null
+ order by c.name`
+ );
+ return rows;
+ } catch {
+ return [];
+ }
+}
+
+export default async function Characters() {
+ const chars = await getCharacters();
+ return (
+ <section>
+ <h1>Characters</h1>
+ <p style={{ color: "var(--muted)", fontSize: 13 }}>
+ The people of the 90210 ZIP code — who they are, who plays them, and where they turn up.
+ </p>
+ <div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", marginTop: 16 }}>
+ {chars.map((c) => (
+ <a key={c.name} className="card" href={`/characters/${slugify(c.name)}`} style={{ display: "block", color: "inherit" }}>
+ <strong style={{ fontFamily: "var(--font-display)", fontSize: 16 }}>{c.name}</strong>
+ {c.actor ? <span style={{ color: "var(--muted)", fontSize: 12 }}> · {c.actor}</span> : null}
+ <p style={{ margin: "5px 0 0", color: "var(--ink-soft)", fontSize: 12.5, lineHeight: 1.5 }}>
+ {c.description_original}
+ </p>
+ {c.appearances > 0 ? (
+ <span className="pill" style={{ marginTop: 8, display: "inline-block" }}>
+ {c.appearances} appearances
+ </span>
+ ) : null}
+ </a>
+ ))}
+ </div>
+ </section>
+ );
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 8060bd0..1df2cc5 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -47,6 +47,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<a href="/show">Series</a>
<a href="/episodes">Episodes</a>
<a href="/cast">Cast</a>
+ <a href="/characters">Characters</a>
<a href="/news">News</a>
<a href="/games">Games</a>
<a href="/community">Community</a>
diff --git a/apps/web/app/show/page.tsx b/apps/web/app/show/page.tsx
index be66f21..7664585 100644
--- a/apps/web/app/show/page.tsx
+++ b/apps/web/app/show/page.tsx
@@ -1,5 +1,5 @@
import { pool } from "@/lib/db";
-import { castPath } from "@nineoh/core";
+import { castPath, slugify } from "@nineoh/core";
export const revalidate = 3600; // ISR: cached HTML, hourly background refresh
export const metadata = {
@@ -111,7 +111,7 @@ export default async function Show() {
<h2>Characters</h2>
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))" }}>
{characters.map((c) => (
- <div key={c.name} className="card">
+ <a key={c.name} className="card" href={`/characters/${slugify(c.name)}`} style={{ display: "block", color: "inherit" }}>
<strong style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{c.name}</strong>
{c.actor ? (
<span style={{ color: "var(--muted)", fontSize: 12 }}> · {c.actor}</span>
@@ -119,7 +119,7 @@ export default async function Show() {
<p style={{ margin: "4px 0 0", color: "var(--ink-soft)", fontSize: 12.5, lineHeight: 1.5 }}>
{c.description_original}
</p>
- </div>
+ </a>
))}
</div>
</>
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
index 2b67be9..661d3e6 100644
--- a/apps/web/app/sitemap.ts
+++ b/apps/web/app/sitemap.ts
@@ -1,6 +1,6 @@
import type { MetadataRoute } from "next";
import { pool } from "@/lib/db";
-import { SITE_URL, episodePath, castPath } from "@nineoh/core";
+import { SITE_URL, episodePath, castPath, slugify } from "@nineoh/core";
import { GAMES } from "@/lib/games";
export const dynamic = "force-dynamic";
@@ -11,6 +11,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
"/show",
"/episodes",
"/cast",
+ "/characters",
"/news",
"/games",
"/community",
@@ -39,6 +40,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
for (const c of cast.rows) {
urls.push({ url: `${SITE_URL}${castPath(c.name)}`, changeFrequency: "monthly", priority: 0.5 });
}
+ const chars = await pool.query(
+ `select name from characters where description_original is not null order by name`
+ );
+ for (const c of chars.rows) {
+ urls.push({ url: `${SITE_URL}/characters/${slugify(c.name)}`, changeFrequency: "monthly", priority: 0.5 });
+ }
} catch {
/* DB unreachable at build — static routes still returned */
}
← 78a8f84 loop refine 3/n: ISR (hourly revalidate) on stable pages (ho
·
back to Nineoh Guide
·
chore: version-up to v0.2.0 (session close) — code tsc/CTA/5 b3f641d →