# Games List, Contact Page & SEO — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a browsable games list page with search/filter/infinite scroll, a contact/report page with Discord webhook, and update SEO/sitemap coverage. **Architecture:** Server components for initial data + SEO, client components for interactivity. New Elysia route for contact submission. Existing CRUD endpoint enhanced with a dedicated listing route for paginated games with stats. Discord webhook for contact form. Sitemap and robots.txt updated for new pages. **Tech Stack:** Next.js 16 (App Router, Server Components), Elysia API, Drizzle ORM, Tailwind v4, Lucide React icons, motion/react (framer-motion) --- ## Phase 1: Games Listing API ### Task 1: Create games listing API endpoint **Files:** - Create: `lib/api/games-listing.ts` - Modify: `lib/api/index.ts` - Modify: `app/api/[[...slugs]]/route.ts` - [ ] **Step 1: Create `lib/api/games-listing.ts`** This endpoint returns paginated games enriched with benchmark count and platform support status, supporting search, genre filter, device filter, and sort. ```typescript import { Elysia, t } from "elysia" import { db } from "@/lib/db/index" import { games, gameVersions, performanceEntries, gamePlatformSupport, hardware, } from "@/lib/db/schema" import { ilike, or, sql, eq, and, desc, asc, inArray } from "drizzle-orm" const MAX_OFFSET = 10000 const PAGE_SIZE = 24 export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get( "/", async ({ query, set }) => { const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET) const limit = Math.min(Number(query.limit) || PAGE_SIZE, 100) const search = query.search || "" const genre = query.genre || "" const device = query.device || "" const sort = query.sort || "recent" // Build where conditions const conditions = [] if (search) { const term = `%${search}%` conditions.push( or( ilike(games.title, term), ilike(games.developer, term), ilike(games.publisher, term), )!, ) } if (genre) { conditions.push(sql`${games.genres} @> ${JSON.stringify([genre])}::jsonb`) } if (device) { // Sub-select game IDs that have platform support for this device const supportedIds = await db .select({ gameId: gamePlatformSupport.gameId }) .from(gamePlatformSupport) .where( and( eq(gamePlatformSupport.hardwareSlug, device), eq(gamePlatformSupport.isSupported, true), ), ) if (supportedIds.length > 0) { conditions.push(inArray(games.id, supportedIds.map((s) => s.gameId))) } else { // No games support this device return { data: [], total: 0, limit, offset, genres: [], devices: [] } } } const where = conditions.length > 0 ? and(...conditions) : undefined // Determine sort order let orderBy switch (sort) { case "name": orderBy = asc(games.title) break case "benchmarks": orderBy = desc(sql`benchmark_count`) break case "recent": default: orderBy = desc(games.createdAt) break } // Fetch games with benchmark counts const gamesQuery = db .select({ id: games.id, steamAppId: games.steamAppId, title: games.title, developer: games.developer, capsuleImage: games.capsuleImage, headerImage: games.headerImage, genres: games.genres, source: games.source, createdAt: games.createdAt, benchmarkCount: sql`( SELECT count(*)::int FROM ${performanceEntries} INNER JOIN ${gameVersions} ON ${performanceEntries.versionId} = ${gameVersions.id} WHERE ${gameVersions.gameId} = ${games.id} AND ${performanceEntries.isRemoved} = false )`, }) .from(games) .where(where) .orderBy(sort === "benchmarks" ? desc(sql`benchmark_count`) : orderBy) .limit(limit) .offset(offset) // Count total const countQuery = db .select({ count: sql`count(*)::int` }) .from(games) .where(where) // Fetch all genres (for filter options) const genreRows = await db .select({ genres: games.genres }) .from(games) .where(sql`${games.genres} IS NOT NULL`) const genreSet = new Set() for (const row of genreRows) { if (Array.isArray(row.genres)) { for (const g of row.genres) { if (typeof g === "string") genreSet.add(g) } } } // Fetch all hardware devices (for filter options) const deviceRows = await db .select({ slug: hardware.slug, name: hardware.name }) .from(hardware) .orderBy(hardware.sortOrder) const [data, countResult] = await Promise.all([gamesQuery, countQuery]) // Fetch platform support for the returned games const gameIds = data.map((g) => g.id) let platformMap = new Map() if (gameIds.length > 0) { const platformRows = await db .select({ gameId: gamePlatformSupport.gameId, protonStatus: gamePlatformSupport.protonStatus, }) .from(gamePlatformSupport) .where(inArray(gamePlatformSupport.gameId, gameIds)) for (const row of platformRows) { // Use the first platform support entry found if (!platformMap.has(row.gameId)) { platformMap.set(row.gameId, row.protonStatus) } } } const enrichedData = data.map((g) => ({ id: g.id, steamAppId: g.steamAppId, title: g.title, developer: g.developer, capsuleImage: g.capsuleImage, headerImage: g.headerImage, genres: g.genres, source: g.source, benchmarkCount: g.benchmarkCount, deckStatus: platformMap.get(g.id) ?? null, })) return { data: enrichedData, total: countResult[0]?.count ?? 0, limit, offset, genres: Array.from(genreSet).sort(), devices: deviceRows, } }, { query: t.Object({ offset: t.Optional(t.String()), limit: t.Optional(t.String()), search: t.Optional(t.String()), genre: t.Optional(t.String()), device: t.Optional(t.String()), sort: t.Optional(t.String()), }), }, ) ``` - [ ] **Step 2: Export from `lib/api/index.ts`** Add this line to the existing barrel export: ```typescript export { gamesListingRoutes } from "./games-listing" ``` - [ ] **Step 3: Register route in `app/api/[[...slugs]]/route.ts`** Add import: ```typescript import { gamesListingRoutes } from "@/lib/api/games-listing" ``` Add `.use(gamesListingRoutes)` after `.use(gamesRoutes)`: ```typescript // Games + Versions + Listing .use(gamesRoutes) .use(gameVersionsRoutes) .use(gamesListingRoutes) ``` - [ ] **Step 4: Verify the API starts** Run: `bun run dev` and confirm no compilation or startup errors. Hit `http://localhost:3000/api/games/listing` and verify it returns a JSON response with `data`, `total`, `genres`, `devices` fields. - [ ] **Step 5: Commit** ```bash git add lib/api/games-listing.ts lib/api/index.ts app/api/\[\[...slugs\]\]/route.ts git commit -m "feat: add games listing API endpoint with search, genre/device filter, and sort" ``` --- ## Phase 2: Games List Page ### Task 2: Create games list server component (`page.tsx`) **Files:** - Create: `app/games/page.tsx` - [ ] **Step 1: Create `app/games/page.tsx`** Server component that fetches initial games data, all genres, all devices, and renders metadata + JSON-LD. Passes serialized data to client component. ```typescript import type { Metadata } from "next" import { db } from "@/lib/db/index" import { games, gameVersions, performanceEntries, gamePlatformSupport, hardware, } from "@/lib/db/schema" import { sql, eq, and, desc, inArray } from "drizzle-orm" import { GamesPageClient } from "./games-page-client" export const metadata: Metadata = { title: "Games — DeckyVault", description: "Browse the full catalog of Steam Deck games with benchmarks, community settings, and performance data. Filter by genre, device, and more.", keywords: [ "Steam Deck games", "game benchmarks", "Steam Deck settings", "game catalog", "performance data", ], alternates: { canonical: "https://deckyvault.xyz/games" }, openGraph: { title: "Games — DeckyVault", description: "Browse the full catalog of Steam Deck games with benchmarks and performance data.", url: "https://deckyvault.xyz/games", siteName: "DeckyVault", type: "website", }, } export default async function GamesPage() { // Fetch initial 24 games with benchmark counts const gamesData = await db .select({ id: games.id, steamAppId: games.steamAppId, title: games.title, developer: games.developer, capsuleImage: games.capsuleImage, headerImage: games.headerImage, genres: games.genres, source: games.source, createdAt: games.createdAt, benchmarkCount: sql`( SELECT count(*)::int FROM ${performanceEntries} INNER JOIN ${gameVersions} ON ${performanceEntries.versionId} = ${gameVersions.id} WHERE ${gameVersions.gameId} = ${games.id} AND ${performanceEntries.isRemoved} = false )`, }) .from(games) .orderBy(desc(games.createdAt)) .limit(24) // Get total count const [{ count: totalCount }] = await db .select({ count: sql`count(*)::int` }) .from(games) // Fetch platform support for initial games const gameIds = gamesData.map((g) => g.id) const platformRows = gameIds.length > 0 ? await db .select({ gameId: gamePlatformSupport.gameId, protonStatus: gamePlatformSupport.protonStatus, }) .from(gamePlatformSupport) .where(inArray(gamePlatformSupport.gameId, gameIds)) : [] const platformMap = new Map() for (const row of platformRows) { if (!platformMap.has(row.gameId)) { platformMap.set(row.gameId, row.protonStatus) } } // Fetch all genres const genreRows = await db .select({ genres: games.genres }) .from(games) .where(sql`${games.genres} IS NOT NULL`) const genreSet = new Set() for (const row of genreRows) { if (Array.isArray(row.genres)) { for (const g of row.genres) { if (typeof g === "string") genreSet.add(g) } } } // Fetch all hardware devices const deviceRows = await db .select({ slug: hardware.slug, name: hardware.name }) .from(hardware) .orderBy(hardware.sortOrder) // Serialize for client const initialGames = gamesData.map((g) => ({ id: g.id, steamAppId: g.steamAppId, title: g.title, developer: g.developer, capsuleImage: g.capsuleImage, headerImage: g.headerImage, genres: g.genres, source: g.source, benchmarkCount: g.benchmarkCount, deckStatus: platformMap.get(g.id) ?? null, })) const allGenres = Array.from(genreSet).sort() const allDevices = deviceRows // JSON-LD ItemList const jsonLd = { "@context": "https://schema.org", "@type": "ItemList", itemListElement: initialGames.map((game, i) => ({ "@type": "ListItem", position: i + 1, name: game.title, url: `https://deckyvault.xyz/game/${game.id}`, })), } return ( <>