SEO
Tudo o que o template emite pra buscadores: meta e JSON-LD por rota, hreflang multi-pais, canonical, sitemaps e robots.txt.
O dominio tem 8 arquivos de config em app/config/seo/ — todos brand-overridable, todos importados como ~/config/seo/<arquivo>:
| Arquivo | Export | Server-only | Papel |
|---|---|---|---|
seo.ts | seoConfig | — | Knobs de brand: verificacao, OG fallback, sameAs, contatos, licencas, canonical overrides |
hreflang.ts | hreflangConfig | — | <link rel="alternate" hreflang> das brands-irmas |
sibling-route-paths-7k.ts | bet7kBrRoutePaths, cl7kRoutePaths, fi7kRoutePaths, ng7kRoutePaths | — | Mapas de rota das brands-irmas 7k (usados pelo hreflang) |
sitemap.ts | sitemapConfig | — | Grupos extras de sitemap + overrides de priority |
page-descriptions.server.ts | pageDescriptions | sim | Meta e descriptions das paginas nao-jogo |
games-seo.server.ts | gamesSeoConfig | sim | Meta de <head> por jogo curado |
sports-seo.ts | sportsSeoConfig | — | Copy de SEO em torno do sportsbook |
branded-home-links.ts | brandedHomeLinksConfig | — | Links internos pra home com anchor de marca |
Utils e services correlatos: app/utils/seo.ts, app/utils/meta.ts, app/utils/games-seo.server.ts, app/utils/sports-seo.ts, app/utils/sitemap.server.ts, app/services/sitemap-config.server.ts, app/services/sitemap-content.server.ts.
seoConfig — knobs de brand
Default no base e um objeto vazio: campo ausente = zero markup. Cada brand popula o que usa em overrides/<brand>/app/config/seo/seo.ts.
Todos os valores terminam no HTML publico — nao ha segredo aqui.
| Campo | Emite |
|---|---|
googleSiteVerification | <meta name="google-site-verification"> |
facebookDomainVerification | <meta name="facebook-domain-verification"> |
pinterestSiteVerification | <meta name="p:domain_verify"> |
yandexVerification | <meta name="yandex-verification"> |
fallbackOgImage | og:image quando a rota nao define a propria |
socialLinks | Organization.sameAs[] no JSON-LD |
contactPoints | Organization.contactPoint[] (contactType, telephone, email, areaServed, availableLanguage) |
identifiers | Organization.identifier[] como PropertyValue |
disambiguatingDescription | Organization.disambiguatingDescription |
foundingDate | Organization.foundingDate (ISO 8601) |
alternateName | alternateName no Organization / WebSite |
searchUrlTemplate | Template do SearchAction (deve conter {search_term_string}) |
websiteSchemaScope | "global" (default) ou "homepage-only" |
canonicalOverrides | <link rel="canonical"> por path |
Licencas regulatorias
identifiers conecta a brand ao registro regulatorio no Knowledge Graph. No Brasil, quando a brand e licenciada pela SPA-MF, inclua o numero da licenca com propertyID: "SPA-MF" — o sinal ajuda a diferenciar operador licenciado de site de mercado cinza:
identifiers: [
{ propertyID: "SPA-MF", value: "12345/2024", name: "Licença SPA-MF" },
{ propertyID: "CNPJ", value: "12.345.678/0001-00" },
];
websiteSchemaScope
"global"(default) —WebSiteJSON-LD emitido no root layout, em toda pagina. Compativel com brands que nao opt-in."homepage-only"— pulado no root layout, emitido apenas pela home. Recomendado pela spec do Google:WebSite+SearchActiondescrevem o site como um todo e devem aparecer uma vez, na home.
canonicalOverrides
Mapeia um pathname normalizado (sem trailing slash, sem query/hash — ver normalizeHreflangPath) pro path que o canonical deve apontar. resolveCanonicalHref() (app/utils/seo.ts) aplica.
canonicalOverrides: { "/games/category/all": "/" }
:::caution Use com parcimonia O default e canonical auto-referente (cada URL canonicaliza pra si). Este override existe pra consolidar sinal de ranking quando uma subpagina canibaliza uma pagina mais importante em queries de marca — o caso classico e uma pagina de categoria de alto trafego superando a home pelo nome da marca. A pagina segue indexada e servindo as queries dela; so o canonical e reatribuido.
Isso muda apenas a tag canonical. O og:url continua auto-referente, pra que o card social aponte pra pagina em que o usuario esta.
:::
Hreflang
<link rel="alternate" hreflang="…" href="…"> injetado no <head> de todas as paginas (via _layout.tsx), sinalizando ao Google as variantes da brand em outros idiomas/regioes.
Default no base: array vazio → nenhuma tag emitida. Brands com sister-sites sobrescrevem overrides/<brand>/app/config/seo/hreflang.ts listando todos os pares, incluindo o proprio site e x-default.
HreflangEntry
export interface HreflangEntry {
/** `language[-REGION]` ou `x-default`. Ex: `pt-BR`, `es-CL`, `x-default`. */
hreflang: string;
/** Origin da variante — sem trailing slash, sem path, sem query. */
origin: string;
/** Mapa de rotas da brand-irma (mesmo shape do `~/config/routes/paths` dela). */
routePaths?: RoutePathMap;
/** Regex de paths que nao existem nesse locale — href cai na home da variante. */
fallbackToHome?: RegExp[];
}
O reverse-match de rota
Este e o mecanismo central e o menos obvio. Quando o entry traz routePaths, o _layout.tsx traduz o pathname atual pra URL equivalente no idioma da variante — /cassino/ao-vivo em BR vira /casino/en-vivo em CL. A traducao e feita por translatePathToSibling() (app/utils/seo.ts):
- Itera as rotas locais (
routePaths) commatchPathpra descobrir arouteKey+paramsda URL atual. - Re-resolve com o pattern da irma via
generatePath. - Se nenhuma rota local bate (conteudo dinamico fora do registry, por exemplo), retorna
null— e o caller cai pro pathname local cru.
A ordem de tentativa e por especificidade, nao pela ordem do objeto:
- mais segmentos literais primeiro (sem
:paramnem*); - em empate, patterns sem
*antes dos com*(/sportsantes de/sports/*); - em empate final, mais segmentos totais.
Sem isso, /games/category/slots casaria casino.play (/games/:provider/:game — 1 literal, 2 params) em vez de casino.category (/games/category/:slug — 2 literais, 1 param), porque ambos tem 3 segmentos.
Precedencia no _layout.tsx
Pra cada entry, na ordem:
fallbackToHomematcha o pathname? →href = origin + "/"- o entry tem
routePaths? →translatePathToSibling(), com fallback pro pathname local - senao → concatena o pathname local cru
O caso 3 e o que garante a auto-referencia (o entry do proprio dominio nao deve declarar routePaths: o routePaths local ja produz o path correto).
fallbackToHome
Lista de regex pra paths que nao existem naquele locale. Quando o pathname atual matcha, o href emitido aponta pra home (/) da variante em vez de concatenar um path quebrado. E a "Opcao B" do parecer de SEO: manter hreflang valido apontando pra raiz da brand alternativa.
Entries de auto-referencia devem deixar undefined — o path sempre existe no proprio site.
Exemplo real (overrides/7k-bet-br/app/config/seo/hreflang.ts)
const BR_ONLY: RegExp[] = [/^\/blog(\/|$)/, /^\/promocoes(\/|$)/, /^\/legal(\/|$)/];
export const hreflangConfig: HreflangEntry[] = [
{ hreflang: "pt-BR", origin: "https://7k.bet.br" },
{
hreflang: "es-CL",
origin: "https://bet7k.cl",
routePaths: cl7kRoutePaths,
fallbackToHome: BR_ONLY,
},
// en-NG, fi-FI …
{ hreflang: "x-default", origin: "https://7k.bet.br" },
];
x-default aponta pro mercado-mae: usuarios em locales nao mapeados recebem a URL canonica em vez de uma variante geo aleatoria.
sibling-route-paths-7k.ts — e por que ele vive no base
Os mapas de rota das quatro brands 7k irmas ficam no base, nao em cada override:
:::warning Por que nao importar o routePaths da irma direto
O brandOverridesPlugin e file-replacement: um override de ~/config/routes/paths resolve so pro build da propria brand. Um build de 7k-bet-br nao consegue importar o ~/config/routes/paths de cl-bet7k-com. A saida e espelhar os mapas a mao aqui, uma vez, e deixar cada override 7k importar as irmas de que precisa.
Manter em sincronia com overrides/<brand>/app/config/routes/paths.ts. O quality gate do pre-push nao pega drift entre este arquivo e os overrides das brands — o unico sintoma e uma URL de hreflang errada em producao. A ordem dos segmentos no arquivo segue a das fontes justamente pra tornar o diff linha-a-linha trivial.
:::
Descriptions por pagina
page-descriptions.server.ts
Meta e texto visivel das paginas nao-jogo. Server-only.
Estrutura em duas camadas:
defaults— porrouteKey, aplicado quando nao ha override especificopages— override porrouteKey:slug(rotas dinamicas) ou porrouteKey(rotas estaticas). Campos omitidos herdam do default correspondente
| Campo | Uso | Formato |
|---|---|---|
meta_title | <title> e og:title | texto puro |
meta_description | <meta description> e JSON-LD | texto puro |
front_description | Visivel na pagina (expansivel) | HTML |
Template tags resolvidas em runtime: {brand_name}, {category_name}, {provider_name}.
Override: overrides/<brand>/app/config/seo/page-descriptions.server.ts — hoje todas as brands do base tem o proprio.
games-seo.server.ts
Overrides de <head> por jogo curado. Complementa dois vizinhos, e a divisao importa:
| Arquivo | Cobre |
|---|---|
app/config/content/game-details.server.ts | Conteudo rico de corpo (ver Games) |
app/config/content/faq.server.ts | Q&A + FAQPage JSON-LD |
app/config/seo/games-seo.server.ts | Somente os knobs de meta |
GameSeoEntry (keyed pelo slug completo "provider/game"):
| Campo | Efeito |
|---|---|
title | Sobrescreve <title> e og:title |
description | Sobrescreve <meta description> e og:description |
alternateName | alternateName no Schema.org |
ogTitle / ogDescription / ogImage | OG dedicado (cai em title/description) |
h1 | H1 visivel na pagina do jogo (cai no nome do jogo) |
h3 | Subtitulo entre o H1 e o iframe |
ratingValue / ratingCount | AggregateRating no JSON-LD de VideoGame |
emitJsonLdGameGraph | Emite um grafo Game completo ao lado do VideoGame base |
O base envia copy templatizada pros 10 jogos mais populares no BR. {brand_name} e resolvido em request time por getGameSeo(slug, brandName) (app/utils/games-seo.server.ts) — a copy do base fica generica e qualquer brand sem override ainda recebe meta correta pra marca dela.
sports-seo.ts
Copy de SEO renderizada em torno da pagina de sportsbook, pra crawlers. Os blocos HTML sao serializados numa unica string por getSportsSeoHtml() e renderizados pelo componente compartilhado PageDescription (colapsado por default com "Ver mais", igual cassino/promocoes/VIP). resolveSportsSeoMeta(pathname, brandName) resolve o meta por rota.
:::note Quando o conteudo nao renderiza
Mesmo populado, o bloco fica escondido quando a brand usa o sportsbook First com o footer delegado ao iframe. O gate esta em app/routes/sports/_layout.tsx (shouldRenderSeoContent). Ver Sports.
:::
branded-home-links.ts
Links internos apontando pra home com anchor de marca, renderizados no HTML de servidor de paginas hub de alto trafego.
Racional (plano de recuperacao de query de marca): a home pode perder a posicao no SERP do proprio nome da marca pra uma subpagina de alto trafego. Links internos daquelas subpaginas pra /, com anchors contendo o nome da marca, reforcam que a home e o destino relevante pra queries de marca.
:::warning Tem que estar no HTML de servidor Um link client-only e invisivel pro Googlebot. :::
export interface BrandedHomeLinkEntry {
/** Anchor visivel — deve conter o nome da marca. */
anchor: string;
/** Frase envolvente opcional com placeholder `{link}`. */
sentence?: string;
}
export type BrandedHomeLinksConfig = Record<string, BrandedHomeLinkEntry>;
A chave e um intent key estavel, usado na pagina via <BrandedHomeLink intent="…">. Chave desconhecida ou ausente nao renderiza nada. Default no base: {} — nenhuma brand opt-in sem override.
Sitemaps
Existem 9 arquivos de rota de sitemap em app/routes/ — cada um um handler proprio:
| Rota | Arquivo | Conteudo |
|---|---|---|
/sitemap.xml | sitemap[.]xml.ts | Index — aponta pros filhos |
/sitemap-static.xml | sitemap-static[.]xml.ts | Paginas estaticas do registry |
/sitemap-games/categories.xml | sitemap-games.categories[.]xml.ts | Categorias de jogo |
/sitemap-games/providers.xml | sitemap-games.providers[.]xml.ts | Providers |
/sitemap-games/:provider.xml | sitemap-games.$provider[.]xml.ts | Jogos de um provider (com sharding) |
/sitemap/:area/:filename | sitemap-content.$area.$filename.ts | Conteudo dinamico (blog, promotions, news, faq, help, responsibleGaming) |
/sitemap-faq.xml | sitemap-faq[.]xml.ts | FAQ |
/sitemap-profiles.xml | sitemap-profiles[.]xml.ts | Perfis oficiais |
sitemap-brand-group[.]xml.ts | (handler compartilhado) | Grupos declarados pela brand |
:::info sitemap-legal.xml foi removido
Removido em 2026-07: paginas legais sao noindex por decisao de produto, e anunciar URL noindexada num sitemap e contraditorio.
:::
Slugs reservados
/sitemap-games/{categories,providers}.xml sao registrados antes da rota dinamica /sitemap-games/:provider.xml. A ordenacao por especificidade do React Router ja resolveria, mas a ordem explicita documenta que categories e providers sao slugs reservados que um provider real nunca pode reivindicar. Se um provider com um desses slugs aparecer no catalogo, a rota dinamica nunca o ve e aqueles jogos saem do SEO.
Formatos do sitemap de conteudo
Uma rota (/sitemap/:area/:filename) cataloga todos os formatos:
/sitemap/blog/dicas.xml → posts da categoria "dicas" (shard 1)
/sitemap/blog/dicas-2.xml → shard 2 (>= 500 posts)
/sitemap/blog/categorias.xml → URLs das paginas de categoria
/sitemap/blog/post.xml → posts sem categoria (canonical = null)
:param no RR7 so funciona depois de /, entao o primeiro segmento (/sitemap) e literal e :area + :filename sao os dois params.
Grupos por brand — sitemapConfig
export const sitemapConfig: SitemapConfig = {
groups: [], // default: nenhum extra
};
| Campo | Descricao |
|---|---|
groups: SitemapGroupDef[] | Grupos extras. Cada path vira um XML separado |
routeOverrides?: { rules } | Override de priority/changeFreq nas rotas shared existentes |
SitemapGroupDef:
| Campo | Descricao |
|---|---|
path | "sitemap-blog.xml" (dash-prefixed) ou "sitemap/promocoes/promocoes.xml" (nested) |
source | { kind: "static-list", entries } ou { kind: "wp-content", area, categorySlug? } |
rules? | SitemapPriorityRule[] — a primeira que match aplica; ponha as especificas primeiro |
defaultRule? | Aplicado quando nenhuma rule match |
As areas aceitas pelo wp-content: blog, promotions, faq, help, news, responsibleGaming.
SitemapPriorityRule.pattern casa contra o path relativo e aceita exact match, prefixo com wildcard (/blog/*), wildcard de segmento (/cassino/categoria/*) e glob com multiplos coringas.
:::warning Cada path de grupo precisa de um route(...) explicito
O RR7 nao aceita :param depois de - (so depois de /), entao cada path dash-prefixed e registrado individualmente em app/router/routes.ts — hoje sao ~29 registros apontando pro mesmo handler (sitemap-brand-group[.]xml.ts), que le o pathname do request e procura o group correspondente na config da brand. 404 quando a brand nao declara o group.
Nenhuma brand do base declara app/config/seo/sitemap.ts hoje, entao todos esses paths respondem 404 no base. Pra adicionar um grupo novo: declare em sitemapConfig.groups e adicione o route(...) correspondente.
:::
routeOverrides cobre as rotas shared (sitemap-static.xml, sitemap-games/*, sitemap-faq.xml, sitemap/{area}/{filename}): cada rota gera as URLs como sempre e applyRouteOverrides() (app/services/sitemap-config.server.ts) faz lookup do loc nas rules. Sem opt-in, o comportamento default e preservado.
Helpers
app/utils/sitemap.server.ts concentra a mecanica: renderUrlset(), renderSitemapIndex(), sitemapResponse(), sitemapNotFoundResponse(), publicOrigin(), resolveSitemapOrigin(), formatLastmod(), dayRoundedNow(), resolveImageDeliveryUrl(), mais o sharding (SITEMAP_GAMES_SHARD_THRESHOLD, hashSlug(), computeShardCount(), shardGames(), groupGamesByProvider()).
robots.txt
app/routes/robots[.]txt.ts tem comportamento duplo, decidido pelo host da request.
Host canonico
PUBLIC_DOMAIN quando setado, senao ORIGIN_DOMAIN. PUBLIC_DOMAIN cobre ambientes em que o dominio servido ao publico difere do ORIGIN_DOMAIN (o caso bet7k.cl: publico em bet7k.cl, plataforma/BFF e override de brand keyed em cl.bet7k.com, que hoje e so um 301 pro dominio publico).
:::caution PUBLIC_DOMAIN substitui o canonico, nao adiciona um segundo
Um hit que chega pelo ORIGIN_DOMAIN nesses ambientes cai no ramo non-prod — o que e o comportamento seguro pra um dominio que redireciona.
:::
Prod (host da request === host canonico)
Allow: / + a lista de disallow + ponteiro pro sitemap index no mesmo host. Cache public, max-age=86400.
Paths bloqueados e por que:
| Path | Motivo |
|---|---|
/api/ | Endpoints server-only, sem conteudo pro usuario |
/debug | Paineis de debug in-app |
/user/ | Area autenticada |
/signup | Formulario de cadastro, sem valor organico |
/recover | Recuperacao de senha |
/__/ | Internals do framework (resource routes) |
/game-error | Superficie de erro de launch, so pra iframe |
/redirect | Rota de redirect |
routePaths.search, routePaths["search.casino"], routePaths["search.sports"] | Busca interna — paginas finas, explicitamente citadas como conteudo de baixa qualidade pelas Search Quality Guidelines |
Os tres paths de busca sao resolvidos via routePaths, entao overrides de brand (/buscar, por exemplo) sao respeitados automaticamente.
Non-prod (stage, preview, dev, ou qualquer mismatch)
Disallow: / e header X-Robots-Tag: noindex, nofollow, cache 300s. Sem linha Sitemap: — se um crawler ignorar o Disallow, ao menos nao entregamos o sitemap de bandeja.
Em dev local nao ha o que ajustar: o dev server roda em localhost enquanto o .dev.vars carrega o dominio real da brand, entao localhost cai automaticamente no ramo non-prod — que e o comportamento desejado.
:::note Scheme forcado
publicOrigin() forca https em host nao-local. Uma zona com SSL "Flexible" (caso bet7k.cl) entrega request.url com scheme http pro worker, e a linha Sitemap: saia com http:// em producao.
:::
Utils de meta
| Util | Papel |
|---|---|
buildMeta({ title, description, url, image, type }) | Conjunto padrao de descriptors de SEO |
composeFullTitle(title, brandName) | Sufixa - ${brandName} quando o titulo ainda nao menciona a marca; titulo vazio cai pra marca sozinha |
normalizeHreflangPath(pathname) | Normaliza pathname pra canonical e hreflang |
resolveCanonicalHref(url) | Aplica seoConfig.canonicalOverrides |
translatePathToSibling(pathname, siblingRoutePaths) | Reverse-match de rota pro hreflang |
buildBreadcrumbsMeta() / buildBreadcrumbJsonLd() / homeCrumb() | Breadcrumbs |
buildOrganizationJsonLd() / buildWebSiteJsonLd() | JSON-LD de entidade |
getOrigin(matches) / getBrandName(matches) | Leitura do loaderData do layout |
app/utils/meta.ts faz o merge com o meta do layout pai. Os hreflang alternates sao chaveados por (rel, hrefLang) de proposito: sem isso, um <link rel="alternate"> emitido por uma rota filha apagaria a lista inteira de hreflangs do layout.
:::warning composeFullTitle precisa do guard de titulo vazio
Sem ele, um seoTitle que resolve null/"" (config de SEO sem entry pra rota, sessao restaurada sem os campos) comporia um sufixo orfao — " - 7K".
:::
Override por brand
Todo arquivo de app/config/seo/ e brand-overridable em overrides/<brand-key>/app/config/seo/<arquivo>.ts, com a semantica padrao de substituicao de arquivo inteiro (sem deep-merge). Ver Override Files.
Cobertura atual (consulte overrides/*/app/config/seo/ pra estado corrente): page-descriptions.server.ts em todas as brands; hreflang.ts na familia 7k (7k-bet-br, cl-bet7k-com, fi-7k-bet, ng-7k-bet); games-seo.server.ts, seo.ts e sports-seo.ts em subconjuntos.