SEO
An inventory of the search-related functionality in apps/web: what exists and where it lives.
Metadata
src/app/layout.tsx declares the defaults every route inherits:
metadataBasefromFRONT_URL, so relative canonicals and image paths resolve to absolute URLs.title.default—MoonCellar — Game Tracker & Database;title.template—%s | MoonCellar.- A site description and a
keywordslist. - Open Graph defaults:
siteName: "MoonCellar",type: "website",locale: "en_US", and/images/og-default.png(1200×630, PNG) as the fallback image. twitter.card: "summary_large_image".
Each route adds its own metadata on top:
| Route | Title | Canonical | Other |
|---|---|---|---|
/ | MAIN_PAGE_META_TITLE via title.absolute | / | own keywords; the template does not apply to the root segment |
/games | Games | /games | own keywords |
/games/[slug] | game name | /games/<slug> | description from game.summary, keywords from name + genres + themes + IGDB keywords, Open Graph block restated in full with the cover as og:image (200×266) |
/games/genre/[slug] | <Genre> Games | /games/genre/<slug> | description carries the game count; robots: { index: false, follow: true } when the genre has fewer than 100 games |
/games/platform/[slug] | <Platform> Games | /games/platform/<slug> | same shape as the genre hub, count fetched with a take: 1 query |
/gauntlet | Gauntlet | /gauntlet | own keywords |
/user/[name] | Profile: <name> | /user/<name> | description from the user's own bio when set |
/user/[name]/[tab] | <Tab label>: <name> | /user/<name>/<tab> | one route per profileTabs entry; robots: { index: false, follow: false } on settings; an unknown tab is a 404 from the page component |
/user/[name]/lists/[slug] | <List> — a list by <author> | /user/<name>/lists/<slug> | description from the list's own text or its game count; robots: { index: false, follow: false } for a private list |
/lists | Lists | /lists | own keywords |
/requests | Requests | — | robots: { index: false, follow: true } |
not-found | Page not found | — | robots: { index: false, follow: false } |
A page-level openGraph object replaces the parent's rather than merging with it, which is why the game and hub routes repeat siteName, type and locale.
When generateMetadata cannot find the entity (game, hub, user, list) it returns title: "Page not found" with robots: { index: false, follow: false }. When the lookup fails for any other reason (an API outage, a 5xx) it returns neutral metadata without robots — Game, Games, Profile, List, or {} on a profile tab — and the page component's own throw renders src/app/error.tsx (ErrorPage), which is never cached.
Status codes
fetchOrNull (src/lib/shared/utils/not-found.utils.ts) turns a 404 from the API into null and rethrows anything else, a 400 included. Routes with a dynamic segment wrap their lookup in React.cache, call it from both generateMetadata and the page component, and call notFound() only from the page component — so a missing entity answers with a real 404 rather than a 200 carrying the not-found page.
The hubs resolve their slug against the full genre or platform list, which is not a fetchOrNull lookup: the list request throws on failure, and the slug is a 404 only when the list loaded and does not contain it. The three game blocks below the lookup fall back to an empty list on error, so one failing query does not take the hub down.
Two routes answer with a permanent redirect (308) instead of a duplicate page: the legacy /user/<name>?list=<tab> goes to /user/<name>/<tab> with the rest of the query kept, and a list opened by an outdated slug goes to its current /user/<name>/lists/<slug>.
No route segment has its own error.tsx: every error reaches src/app/error.tsx, which renders ErrorPage, never NotFoundPage.
The root layout has no Suspense boundary; routes that need one (/games, /lists, /requests, and /user/[name] in its profile layout) declare it inside the route, and GauntletPage declares its own.
Rendering
Everything a crawler needs is in the server response.
/gamesis an async server component. It rebuilds the query string fromsearchParams, parses it with the sameparseQueryFiltersthe client uses, fetches page 1 (take: 60) and passes it to the client page asinitialData.GamesPagecompares the two React Query keys with TanStack'shashKey, so the client reuses the server payload instead of refetching.- The catalogue grid is CSS (
repeat(auto-fill, …)inGamesCards.module.scss), so all 60 game links exist in the HTML without any DOM measurement.react-virtualizedis used only by the dropdown list component. - The game page, both hubs and the homepage fetch their data in the server component and render it directly.
<h1> per page: the homepage banner title, Games on the catalogue, Lists, Gauntlet, the game name on a game page, the hub title on both hubs, the user name on a profile, the list name on a list page, the Requests heading, and the 404 and error pages. The catalogue, Lists, the list page and the Gauntlet render theirs through SectionTitle as="h1".
Visible breadcrumbs (src/lib/shared/ui/Breadcrumbs) appear on the game page, the catalogue, both hubs, the Gauntlet, Lists, list pages, Requests and user profiles.
Structured data
src/lib/shared/ui/JsonLd renders a <script type="application/ld+json"> with < escaped; the builders live in src/lib/shared/utils/json-ld.utils.ts.
| Type | Where | Contents |
|---|---|---|
WebSite + SearchAction | root layout, every page | search endpoint /games?search={search_term_string} |
VideoGame | game page | name, url, description, cover, genres, datePublished from first_release |
AggregateRating | inside VideoGame | only when the game has at least 10 MoonCellar ratings; scale 1–10 |
Review | inside VideoGame | the loaded reviews that have a rating, an author and text and are not spoilers |
BreadcrumbList | game page, catalogue, both hubs, Gauntlet, Lists | the same trail as the visible breadcrumbs |
ItemList | catalogue, both hubs | the games rendered on the page, with position, url and name |
Hub pages
/games/genre/[slug] and /games/platform/[slug] render from one component (src/lib/pages/HubPage) and read only gamesApi.getAll.
| Block | Query |
|---|---|
| Most popular | sortBy: "rating", sortOrder: "desc", types: ["Main Game"], votes, take: 5 |
| Recent releases | sortBy: "first_release", sortOrder: "desc", types: ["Main Game"], years: [null, currentYear], take: 6 |
| All games | take: 30, page 1 |
The vote threshold is 100 for genres with 5 000 games or more and 20 otherwise; the platform hub always uses 20. types: ["Main Game"] keeps DLC and re-releases out of the ranked blocks, and years: [null, currentYear] keeps unreleased titles out of the recent one.
Each hub ends with a link into the catalogue with the filter applied (/games?selectedGenres[]=…, /games?selectedPlatforms[]=…) and a block of sibling links — every genre with at least 100 games, or the first 24 platforms. Hubs have no pagination and read no searchParams, which is what lets them be cached.
The platform hub's "recent releases" hint states that dates are a game's first release anywhere, not its release on that platform.
Caching
| Route | Strategy |
|---|---|
/games/[slug], both hubs | ISR: export const revalidate = 3600 together with generateStaticParams() returning [] — nothing is prerendered at build time, and the route is cached on demand |
/ | force-dynamic; featured platforms come from unstable_cache with a 3600 s revalidate |
/games | dynamic, it reads searchParams |
/lists | dynamic, it reads searchParams and passes page 1 to the client as initialData |
/user/[name], /user/[name]/[tab] | dynamic: the profile layout reads cookies() to decide who is viewing |
/user/[name]/lists/[slug] | dynamic: it reads cookies() (forwarded to by-slug for private lists) and searchParams (page, sort) |
/requests, /gauntlet | static: no server fetch, no revalidate; the client page reads the query string inside its Suspense |
sitemap.xml | force-dynamic, with each upstream call wrapped in unstable_cache at 3600 s |
/img/image-proxy | force-dynamic; the upstream fetch revalidates hourly and the response carries public, max-age=3600, s-maxage=86400 |
A route that reads searchParams cannot be statically cached, which is why the two hubs take no query parameters.
A cached game page is purged on demand by revalidateGamePage (src/lib/entities/game/api/game.actions.ts), called after admin edits and by POST /api/revalidate (header x-revalidate-secret matching REVALIDATE_SECRET, body { slugs: string[] }, up to 200).
robots.txt and sitemap
src/app/robots.ts allows everything for *, disallows /admin and /api, and points at ${FRONT_URL}/sitemap.xml.
src/app/sitemap.ts emits, in order:
- The three static links from the
linksconstant —/,/games,/gauntlet. - Every game:
/games/<slug>withlastModifiedfrom the record and, when the game has a cover, an<image:loc>pointing at/img/image-proxy. - Genre hubs with at least 100 games (
MIN_GAMES_FOR_HUB), slugged withtoSlug. - Every platform hub.
- Every user profile, with
lastModified.
Each of the four upstream calls has its own unstable_cache entry and falls back to an empty list on error, so one failing endpoint does not empty the whole sitemap.
Images
/img/image-proxy(src/app/img/image-proxy/route.ts) streams remote covers. It requireshttps, matches the hostname against an allowlist (IGDB, the S3 and DigitalOcean Spaces buckets), verifies that the upstream content type starts withimage/, and answers 400, 403 or 502 otherwise. It sits outside/apiso thatrobots.txtdoes not block the images the sitemap and Open Graph tags reference.next.config.mjslists the same hosts inimages.remotePatternsfornext/image, plus RetroAchievements, YouTube thumbnails, VNDB, Alphacoders, the API host andlocalhost./_next/static/media/*is served withX-Robots-Tag: noindex.- Game covers use
alt={`${game.name} cover`}; decorative images carry an emptyalt.
Headers
next.config.mjs sends Strict-Transport-Security: max-age=63072000; includeSubDomains; preload on every path.
Search engine verification
public/yandex_058d2e4bbfa6f684.html— Yandex Webmaster.public/74a6b85cd7164d77a0cccb5baae3d563.txt— a key file in the IndexNow format, its body repeating its own name.
meta keywords are declared on the root layout, the homepage, the catalogue, Lists, the Gauntlet, game pages and user profiles.
