← back to Nineoh Guide
go-all: approve S2-S5 recaps (all 114 reviewed) + More-from-Season links on detail + /show series hub (JSON-LD, season breakdown, cast, where-to-watch) + real legal email info@agentabrams.com
0158b8e730da8c2e69709b8cc38380e0a17966a7 · 2026-07-26 08:25:08 -0700 · Steve
Files touched
M apps/web/app/episodes/[season]/[episode]/page.tsxM apps/web/app/layout.tsxA apps/web/app/show/page.tsxM apps/web/app/sitemap.ts
Diff
commit 0158b8e730da8c2e69709b8cc38380e0a17966a7
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 26 08:25:08 2026 -0700
go-all: approve S2-S5 recaps (all 114 reviewed) + More-from-Season links on detail + /show series hub (JSON-LD, season breakdown, cast, where-to-watch) + real legal email info@agentabrams.com
---
apps/web/app/episodes/[season]/[episode]/page.tsx | 35 ++++++++
apps/web/app/layout.tsx | 1 +
apps/web/app/show/page.tsx | 101 ++++++++++++++++++++++
apps/web/app/sitemap.ts | 1 +
4 files changed, 138 insertions(+)
diff --git a/apps/web/app/episodes/[season]/[episode]/page.tsx b/apps/web/app/episodes/[season]/[episode]/page.tsx
index b8a0d53..a4d29d1 100644
--- a/apps/web/app/episodes/[season]/[episode]/page.tsx
+++ b/apps/web/app/episodes/[season]/[episode]/page.tsx
@@ -41,6 +41,20 @@ async function getAdjacent(season: number, episode: number) {
}
}
+async function getSeasonEpisodes(season: number, exclude: number) {
+ try {
+ const { rows } = await pool.query(
+ `select episode_number, title from episodes
+ where season_number=$1 and episode_number<>$2
+ order by episode_number`,
+ [season, exclude]
+ );
+ return rows;
+ } catch {
+ return [];
+ }
+}
+
export async function generateMetadata({
params,
}: {
@@ -70,6 +84,7 @@ export default async function EpisodePage({
const ep = await getEpisode(Number(season), Number(episode));
if (!ep) notFound();
const { prev, next } = await getAdjacent(ep.season_number, ep.episode_number);
+ const seasonEps = await getSeasonEpisodes(ep.season_number, ep.episode_number);
const jsonLd = {
"@context": "https://schema.org",
@@ -156,6 +171,26 @@ export default async function EpisodePage({
<span />
)}
</nav>
+
+ {seasonEps.length > 0 && (
+ <div style={{ marginTop: 24 }}>
+ <h2 style={{ fontSize: 17 }}>More from Season {ep.season_number}</h2>
+ <div
+ style={{
+ display: "grid",
+ gap: 6,
+ gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
+ fontSize: 13,
+ }}
+ >
+ {seasonEps.map((e) => (
+ <a key={e.episode_number} href={`/episodes/${ep.season_number}/${e.episode_number}`}>
+ <span className="se">S{ep.season_number}E{e.episode_number}</span> {e.title}
+ </a>
+ ))}
+ </div>
+ </div>
+ )}
</article>
);
}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index ddefa24..7533dad 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -40,6 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<span className="badge">Unofficial</span>
</a>
<nav className="nav">
+ <a href="/show">Series</a>
<a href="/episodes">Episodes</a>
<a href="/cast">Cast</a>
<a href="/news">News</a>
diff --git a/apps/web/app/show/page.tsx b/apps/web/app/show/page.tsx
new file mode 100644
index 0000000..21ba14d
--- /dev/null
+++ b/apps/web/app/show/page.tsx
@@ -0,0 +1,101 @@
+import { pool } from "@/lib/db";
+import { castPath } from "@nineoh/core";
+
+export const dynamic = "force-dynamic";
+export const metadata = {
+ title: "90210 — Series Overview",
+ description:
+ "An unofficial overview of the 2008 CW teen drama 90210: five seasons, 114 episodes, cast, and where to watch.",
+};
+
+async function getData() {
+ try {
+ const show = await pool.query(
+ "select display_title, description_original from shows limit 1"
+ );
+ const seasons = await pool.query(
+ `select season_number,
+ count(*)::int as eps,
+ min(air_date) as first_aired,
+ max(air_date) as last_aired
+ from episodes group by season_number order by season_number`
+ );
+ const totals = await pool.query(
+ `select count(*)::int as eps,
+ (select count(*)::int from cast_people) as cast,
+ min(air_date) as first, max(air_date) as last
+ from episodes`
+ );
+ const cast = await pool.query(`select name from cast_people order by name`);
+ return {
+ show: show.rows[0] ?? null,
+ seasons: seasons.rows,
+ totals: totals.rows[0],
+ cast: cast.rows,
+ };
+ } catch {
+ return { show: null, seasons: [], totals: null, cast: [] };
+ }
+}
+
+const yr = (d: any) => (d ? new Date(d).getFullYear() : "");
+
+export default async function Show() {
+ const { show, seasons, totals, cast } = await getData();
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "TVSeries",
+ name: "90210 (2008)",
+ description: show?.description_original ?? undefined,
+ numberOfSeasons: seasons.length,
+ numberOfEpisodes: totals?.eps ?? 0,
+ startDate: totals?.first ? new Date(totals.first).toISOString().slice(0, 10) : undefined,
+ endDate: totals?.last ? new Date(totals.last).toISOString().slice(0, 10) : undefined,
+ };
+
+ return (
+ <section>
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
+ <h1>{show?.display_title ?? "90210"} — Series Overview</h1>
+ <p style={{ color: "var(--ink-soft)", lineHeight: 1.6 }}>{show?.description_original}</p>
+ <p style={{ color: "var(--muted)", fontSize: 13 }}>
+ {seasons.length} seasons · {totals?.eps ?? 0} episodes · The CW ·{" "}
+ {yr(totals?.first)}–{yr(totals?.last)} ·{" "}
+ <a href="https://www.justwatch.com/us/tv-show/90210" target="_blank" rel="noopener noreferrer">
+ Where to watch ↗
+ </a>
+ </p>
+
+ <h2>Seasons</h2>
+ <div style={{ background: "var(--paper-2)", border: "1px solid var(--line)", borderRadius: "var(--radius)", overflow: "hidden", boxShadow: "var(--shadow)" }}>
+ {seasons.map((s, i) => (
+ <a
+ key={s.season_number}
+ href={`/episodes#s${s.season_number}`}
+ style={{ display: "flex", gap: 12, padding: "11px 16px", borderTop: i === 0 ? "none" : "1px solid var(--line)", color: "inherit", alignItems: "baseline" }}
+ >
+ <strong className="se" style={{ minWidth: 78 }}>Season {s.season_number}</strong>
+ <span style={{ flex: 1 }}>{s.eps} episodes</span>
+ <span style={{ color: "var(--muted)", fontSize: 12 }}>
+ {yr(s.first_aired)}
+ {yr(s.last_aired) && yr(s.last_aired) !== yr(s.first_aired) ? `–${yr(s.last_aired)}` : ""}
+ </span>
+ </a>
+ ))}
+ </div>
+
+ <h2>Cast at a glance</h2>
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
+ {cast.map((c) => (
+ <a key={c.name} className="pill" href={castPath(c.name)} style={{ textDecoration: "none" }}>
+ {c.name}
+ </a>
+ ))}
+ </div>
+
+ <p style={{ marginTop: 24 }}>
+ <a href="/episodes">Full episode guide →</a>
+ </p>
+ </section>
+ );
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
index b34fece..2b67be9 100644
--- a/apps/web/app/sitemap.ts
+++ b/apps/web/app/sitemap.ts
@@ -8,6 +8,7 @@ export const dynamic = "force-dynamic";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const urls: MetadataRoute.Sitemap = [
"",
+ "/show",
"/episodes",
"/cast",
"/news",
← c2dadda where to watch: JustWatch live-availability links on home +
·
back to Nineoh Guide
·
comprehensive pass: /show season overviews + 14 character pr e200329 →