Skip to main content

SEO Module

Server-side SEO data resolution for TVLK5 pages: meta tags (robots, canonical, hreflang, media screen), schema markup, and page category.

How getSEOData works

For every page request, getSEOData.ts runs two CDN lookups concurrently and merges the results:

┌─> lookupCategoryMatch ──> category SEO data (shared per category)
request ─ Promise.all ─┤
└─> fetchUrlSEOData ──────> per-URL SEO data (specific to this URL)

The data is merged in two stages. First, mergeSEOData.ts resolves the CDN and core-generated layers independently for each field:

per-URL MTP data > category MTP data > core default (constructed from route/config)

After the page has loaded its original SEO data, mergePageSEOData performs the final per-field merge:

resolved MTP field > page seoFallback field > core default field

An MTP value for one field does not replace the whole SEO object. For example, if MTP supplies only metaTitle, the title comes from MTP while description, robots, canonical, and hreflang continue to use their page fallbacks. A missing, null, empty, or whitespace-only scalar MTP value is treated as absent and falls through to the next layer.

If a category matched, the result also carries pageCategory: <categoryName>.

Field-level merge behavior

Pages opt in to their existing values by returning a seoFallback object from their data-fetching function. Each field is resolved independently:

FieldPriority and behavior
metaTitlePer-URL MTP > category MTP > page seoFallback.metaTitle > core default. MTP route placeholders are resolved before merging; an empty or unresolvable value falls through.
metaDescriptionPer-URL MTP > category MTP > page seoFallback.metaDescription > core default. It uses the same placeholder and empty-value handling as title.
metaRobotsPer-URL MTP > category MTP > page seoFallback.metaRobots > core default. Empty scalar MTP values fall through.
canonicalPer-URL MTP > category MTP > page seoFallback.canonical > route-derived core canonical. An MTP value beginning with ? is resolved against the route-derived canonical.
hreflangPer-URL MTP list > category MTP list > page seoFallback.hreflang > route-derived core list. The list is selected as one field rather than merged by locale. An explicitly supplied empty MTP list remains empty and therefore suppresses the page/core fallback list.
schemaMarkupMTP schema properties override matching page schema properties. Unspecified page properties are retained. If only one side is present, that side is used. If either value is invalid JSON or not a plain object, the complete MTP value wins.
mediaScreenTagPer-URL MTP > category MTP > core default. MTP hosts are rewritten to the configured desktop host. There is currently no page seoFallback field for it.
pageCategorySet from the matched MTP category name. It is not read from seoFallback.

Example page fallback:

return {
seoFallback: {
metaTitle: page.title,
metaDescription: page.description,
metaRobots: page.robots,
canonical: page.canonicalUrl,
hreflang: page.alternateLinks,
schemaMarkup: JSON.stringify(page.structuredData),
},
};

Given that fallback, an MTP response containing only { metaTag: { metaTitle: 'MTP title' } } produces the MTP title together with the page's description, robots, canonical, hreflang, and schema markup.

Category matcher

Many URLs share the same category-level SEO data (e.g. all /activities/** pages). Instead of requiring a per-URL JSON file for every page, the category matcher maps a URL path to a category and fetches one shared file per category — fewer CDN objects, better cache hit rate.

Flow (lookupCategoryMatch.ts)

  1. Fetch matcher config.../desktop/_category-matcher-config.json from the SEO CDN, via a 15-min in-memory cache.
  2. Match the pathname — trie traversal against the config; no match → done, fall back to per-URL data only.
  3. Fetch category SEO data.../desktop/{locale}/_category/{categoryName}.json, via a 10-min in-memory cache.

Every step fails soft: any error returns { categoryData: null, categoryName: null } and the page falls back to per-URL + default data.

URL matching (urlCategoryMatcher.ts)

Pure function, no I/O. The config is a trie keyed on path segments (locale prefix like /en-id is stripped first). Each node can match a segment by, in precedence order:

Edge typeMatchesExample
children (literal)exact segmenthotel
params: setsegment in a named value set{culinary, coffee}
params: regexfull-match regex (Java Pattern.matches semantics)\d+
params: anyany segment:slug
wildcardall remaining segments/**

A match returns { categoryName, categoryId }; the trie is produced by seo-backend, this module only consumes it.

Algorithm

The matcher is a depth-first trie traversal with backtracking, one trie level per path segment:

  1. Split the pathname on / and drop the locale segment (/en-id, /id-id, …) if present.
  2. At each node, try edges for the current segment in precedence order: literal children first, then each params edge in config order (set / regex / any), then wildcard.
  3. When an edge matches, recurse into its node with the next segment. If that subtree yields no category, backtrack and try the next edge — a more specific edge never shadows a less specific one that actually leads to a match.
  4. When all segments are consumed, the current node's terminal is the result (no terminal → no match at this depth, backtrack).
  5. wildcard is terminal itself: it swallows all remaining segments and returns immediately — no recursion.

First match in precedence order wins; no match anywhere returns null and the page falls back to per-URL + default data.

Sample config

{
"sets": {
"activityTypes": ["culinary", "coffee", "spa"]
},
"root": {
"children": {
"activities": {
"terminal": { "categoryName": "activities-landing", "categoryId": 10 },
"children": {
"search": {
"wildcard": {
"categoryName": "activities-search",
"categoryId": 11
}
}
},
"params": [
{
"name": "activityType",
"type": "set",
"setRef": "activityTypes",
"node": {
"terminal": {
"categoryName": "activities-type",
"categoryId": 12
}
}
},
{
"name": "activityId",
"type": "regex",
"pattern": "\\d+",
"node": {
"terminal": {
"categoryName": "activities-detail",
"categoryId": 13
}
}
}
]
}
}
}
}

Step-by-step example

Input: /en-id/activities/culinary

StepStateWhat happens
1pathnameSplit → ["en-id", "activities", "culinary"]; en-id matches the locale pattern → segments = ["activities", "culinary"]
2at root, segment activitiesLiteral child activities exists → descend
3at activities node, segment culinaryLiteral children: only search — no match. Param edges next: set edge → sets.activityTypes contains culinary → descend
4at activityType node, segments exhaustedNode has terminal → return it
result{ categoryName: "activities-type", categoryId: 12 }

Other inputs against the same config:

PathnameResultWhy
/en-id/activitiesactivities-landingSegments end on the activities node, which has a terminal
/en-id/activities/search/a/b/cactivities-searchsearch literal, then wildcard swallows a/b/c
/en-id/activities/12345activities-detailNot in activityTypes set → backtrack to next param edge; regex \d+ full-matches 12345
/en-id/activities/random-slugnullNot in set, not numeric, no any/wildcard edge — falls back to per-URL + default SEO data

Caching

Both caches are module-level, in-memory, per server instance (no Redis):

CacheFileTTLBehavior
Matcher configcategoryMatcherConfigCache.ts15 minStale-while-revalidate: expired entries are served immediately while a deduped background refresh runs. A failed refresh keeps the stale config — never evicted after first successful load. Keyed by config URL so staging/prod (apiEnv cookie) never cross-contaminate.
Category SEO datacategorySEODataCache.ts10 minSimple TTL keyed by category JSON URL. Non-200 / errors are not cached. Expired entries are evicted on insert to keep the map bounded.

Monitoring

Both fetch paths emit monitoring.content.seo.apiCount with a source tag to tell them apart:

  • source: 'url' — per-URL fetch, status = HTTP status code
  • source: 'category' — category fetch, status = '200' (data served), '404' (matched but no category file), or 'error' (exception in the lookup)

File map

FileRole
getSEOData.tsEntry point — orchestrates concurrent fetch + merge
utils/lookupCategoryMatch.tsCategory pipeline: config → match → data
utils/urlCategoryMatcher.tsPure trie matcher (pathname → category)
utils/categoryMatcherConfigCache.ts15-min SWR cache for matcher config
utils/categorySEODataCache.ts10-min TTL cache for category SEO data
utils/fetchUrlSEOData.tsPer-URL CDN fetch
utils/mergeSEOData.tsThree-layer field merge (url > category > default)
utils/constructSEOData.tsBuilds the default SEO data from route/config
SEOMetaRenderer.tsxRenders the resolved SEO data into <head>