Skip to main content

SEO Foundation

Overview​

The public web pages get titles, descriptions, canonical URLs, language alternates and share images from apps/web/src/lib/seo.ts. The ID app uses its own metadata helper in apps/id/src/lib/seo.ts for browser tabs and link previews; its pages are excluded from search indexing. All page copy comes from the English and German message catalogs.

Page metadata and language URLs​

Each public web page calls buildPageMetadata(ns, { path, ...options }) in generateMetadata. The helper reads <ns>.meta.title and <ns>.meta.description from apps/web/messages/en.json and apps/web/messages/de.json. The (main) layout supplies the %s - Lumio title template and default description. The home page uses absoluteTitle: true; detail pages can pass titleValues, a fallback title and a data-backed description.

The web helper accepts indexing: "index" | "noindex-follow" | "noindex-nofollow" and hreflang: false where a page should not advertise language alternates. Public song and playlist detail pages use the indexable default; invite links use noindex-nofollow. The legal contact page uses hreflang: true by default, while /withdraw and /cancel disable it. Pages with either noindex setting do not emit alternates.

An explicit valid ?lang=de selects German for one request. ?lang=en selects English. The web and ID proxies validate the parameter, then resolve the language in this order: URL parameter, valid locale cookie, English default. The URL does not redirect. A valid parameter sets the locale cookie only if no cookie exists; it never overwrites an existing preference. Share-image requests ignore ?lang=. See Preferences for the visitor-facing behavior.

For indexed web pages, the canonical URL uses the route path and the explicit language parameter, not the cookie. German is https://lumio.vision/features?lang=de; English and a URL without a valid parameter use https://lumio.vision/features. Other query parameters are omitted. The hreflang links are absolute: en points to the plain URL, de to ?lang=de, and x-default to the plain URL. No canonical or alternates are emitted when the public origin is unknown. ID pages emit neither canonical nor hreflang links.

The ID helper reads <ns>.meta.title and optional <ns>.meta.description from apps/id/messages/{en,de}.json. Its root layout uses common.meta.* for the title template, description and share image.

Crawler rules and sitemap​

LUMIO_APP_URL is read at request time by apps/web/src/lib/seo-env.ts. Only the exact origin https://lumio.vision is indexable. Staging, local, unset and invalid values cause the web proxy to send X-Robots-Tag: noindex, nofollow on responses; page metadata also emits noindex, nofollow. apps/web/src/app/robots.ts allows crawling on production except /invite/, /share/ and /api/, and advertises /sitemap.xml. The share images under /og stay crawlable, because link-preview crawlers such as Twitterbot obey robots.txt for the card image as well; on production the proxy keeps them out of search results with X-Robots-Tag: noindex on every /og and /og/… response, and sends no X-Robots-Tag on page routes. The proxy is the only place that sets the header, so no response carries two. In every other environment, /robots.txt disallows / and names no sitemap.

apps/web/src/app/sitemap.ts generates /sitemap.xml at request time. Its curated routes include the home, features, pricing, public music listings and legal pages; legal document slugs come from LEGAL_SLUGS. It also lists /music/songs/\{id\} for global safe and blocked songs and /music/playlists/\{id\} for system playlists with tracks. Song IDs come from keyset-paged publicSongSitemap reads (up to 5,000 per page, at most 45,000 song URLs in the sitemap); playlist IDs come from publicSystemPlaylists. The music enumeration is cached for one hour. If it fails or the music feature is off, the sitemap still serves the curated routes. Each entry uses the plain English/default URL and includes en, de and x-default alternates; there is no separate German entry. Detail entries use the song's created_at or playlist's updated_at as lastModified when present. An unknown LUMIO_APP_URL produces an empty sitemap. The route does not invent dates, priorities or change frequencies.

The ID app always emits noindex, nofollow in both metadata and X-Robots-Tag. Its /robots.txt disallows /; it has no sitemap. The web proxy publicly serves only the exact crawler paths /robots.txt and /sitemap.xml; ID publicly serves only /robots.txt. Both apps also serve /og paths without a login session. These exemptions do not open similarly named paths such as /robots.txt/x or /og-x.

Stats indexing​

apps/stats/src/lib/seo-env.ts reads LUMIO_STATS_URL at request time. Only https://stats.lumio.vision permits indexing. Staging, production preview, localhost, and an unset or invalid origin send noindex, nofollow in metadata and X-Robots-Tag; dynamic robots.txt disallows /, and sitemap routes return 404. Production robots.txt advertises /sitemap.xml and disallows /api/. It does not disallow noindex pages or /og/, so crawlers and link unfurlers can fetch those resources and see their directives.

The Stats home, ranking, games, emotes and partners pages are indexable. Twitch and Kick channel pages are indexable; YouTube channel pages are noindex, follow. Stream pages and the channel /{channel}/history route are noindex, follow, search results are noindex, follow, and the personal watchlist is noindex, nofollow. The channel page contains current profile and active-panel content only. Its canonical uses the current login returned by publicChannelCard.channelLogin, resolved from the stable channel ID.

/sitemap.xml is an index of /sitemaps/static.xml and zero-based /sitemaps/channels/{page}.xml files. The static file contains the home, five ranking metrics, games, emotes and partners. Each channel file holds at most 45,000 URLs from the API's public_stats.sitemap_platforms allowlist (default Twitch and Kick), with a login and stream start in the last 12 months, after opt-outs are excluded. It never lists stream or history URLs. Its lastmod is the latest stream-start date, and the file count is read at request time.

Structured data​

The home page emits Organization and WebSite JSON-LD through buildHomeJsonLd(). /features emits SoftwareApplication JSON-LD with featureList taken from the cards actually rendered on that page. Its offers come from the same public plans read as /pricing, cached for five minutes: one Offer per plan and billing interval, with the stored decimal price, row currency, P1M or P1Y billing duration and a /pricing URL. A zero-priced yearly offer is omitted. If the plan read fails, the list is empty or any price or currency is invalid, the entire offers property is omitted. The data has no valueAddedTaxIncluded, ratings, reviews or logo URL. apps/web/src/components/seo/json-ld.tsx escapes < before embedding the JSON in a script element. These blocks need a known public origin.

Share images​

The web route apps/web/src/app/og/[locale]/[[...page]]/route.tsx serves 1200 × 630 PNGs at /og/en, /og/de and the /features, /pricing and /music variants. Other web pages use the home banner. The ID route serves one banner per language at /og/en or /og/de. An unknown locale falls back to English; an unknown web page falls back to home. buildPageMetadata puts the active language's image into Open Graph and Twitter metadata when the public origin is known. ID's root layout does the same using AUTH_URL as its public origin.

Both apps render the shared @lumio/ui/og-banner template using their own localized text, 640 × 552 PNG window captures and static Roboto WOFF files. The web text is under marketing.og.*; ID text is under common.meta.og.*. The image URLs carry ?v=<product version> and responses use Cache-Control: public, max-age=31536000, immutable. The proxy does not set a language cookie on /og responses, so a shared cache cannot replay one visitor's cookie to others. The source files and capture instructions live in each app's src/app/og/_banner/shots/README.md and manifest.json.

Lumio Stats share images​

Lumio Stats (apps/stats) has two image routes. /og/[locale]/[[...page]] serves a banner for /, rankings, games, emotes and partners, plus a generic banner without a window. These URLs carry ?v=<stats version> and are cached as immutable. /og/channel/[platform]/[platformChannelId]/[locale] serves a channel card that is drawn from the public card read (GET /v1/public-stats/channels/{platform}/{platform_channel_id}/card). The card is keyed by the stable platform id, never by the handle in the page URL. The id must match ^[A-Za-z0-9_-]{1,64}$ in the OG route and in the API: on GraphQL publicChannelCard and on the REST card read, any other id gets the same Invalid platform channel id validation error (REST 400) before the database is read. One cached read serves both languages, and the response carries Cache-Control: public, max-age=600, s-maxage=900, stale-while-revalidate=300.

The channel image contains only the current display name, handle and aggregate figures. It excludes chatter names, bio, links, team and change history. An unknown or opted-out channel, a disabled public Stats surface or a card-fetch error receives the same generic image. Stats share images carry X-Robots-Tag: noindex on the production host; the footer carries the independent-service platform disclaimer in English and German.

An unknown, opted-out or switched-off channel gets the generic banner, status 200 and Cache-Control: public, max-age=300, s-maxage=300. So do an API error, a timeout and a failed render. The bytes are identical in every one of these cases, so the image does not reveal an opt-out. A failed page banner also answers the generic banner with these headers. If even the generic banner cannot be rendered, both routes answer a plain 1200 × 630 PNG with the same status and headers, so neither route ever answers 5xx.

Rendering a PNG takes about 120 ms of server work, so both routes keep rendered images in memory. A page banner is rendered once per language and page; an unknown language, page or query string resolves to one of these. A channel card is kept for 300 seconds per platform, channel id and resolved language, at most 500 cards. The route still reads the card each time from the 300-second data cache, and changed card data, including an opt-out, replaces the kept image at once. A missing figure renders as "—", never as 0. Channel names and categories use only characters that the bundled Roboto subsets (Latin, Latin Extended, Cyrillic, Greek, Vietnamese) can draw. A name with other characters falls back to the login, and in a category those characters are dropped. This way the renderer never fetches fonts at request time.

Every page passes share to buildPageMetadata in apps/stats/src/lib/seo.ts: index pages pass their banner, and the channel, history and stream pages pass the channel card. /search, /watchlist and the 404 page get the generic banner. On https://stats.lumio.vision, /og/* responses carry X-Robots-Tag: noindex. On every other host they carry the proxy's noindex, nofollow, like every other response. robots.txt never disallows /og/, because X does not show a preview when the image URL is disallowed. Text comes from og.* in apps/stats/messages/{en,de}.json. The windows, the fonts and the capture instructions are in apps/stats/src/app/og/_og/.

Adding a public page or banner​

  1. Add the page's meta.title and meta.description keys in both web message catalogs. Use the namespace in its generateMetadata() call to buildPageMetadata, with the final route path and the appropriate indexing setting. Check the page's canonical and hreflang behavior in both languages.
  2. For a curated route, add its path to SITEMAP_ROUTES in apps/web/src/app/sitemap.ts. Legal document routes follow LEGAL_SLUGS; music detail routes come from the public song and playlist reads. Keep private and tokenized pages out of the list.
  3. For structured data, use JsonLd with properties supported by the rendered page and current product facts. Keep the JSON-LD copy in both message catalogs.
  4. If the page needs its own banner, add its name to OG_PAGES in apps/web/src/lib/og.ts, pass share to buildPageMetadata, add marketing.og.<page>.title and .claim in both languages, and register each localized PNG in apps/web/src/app/og/_banner/assets.ts. Follow shots/README.md to refresh the captures and their manifest. The ID app keeps its own single banner and message keys.

Marketing copy for this surface is kept within marketing.* (including marketing.og.* and marketing.demos.*) and *.meta.* keys in the web message catalogs, and *.meta.* keys in the ID catalogs. The sitemap route array is the curated URL list; changes to marketing copy should not alter unrelated messages or page logic. Review claims against the behavior they describe, especially legal claims about music, plan availability and platform coverage.

LUMIO_APP_URL and AUTH_URL are listed in Installation.