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:
| Field | Priority and behavior |
|---|---|
metaTitle | Per-URL MTP > category MTP > page seoFallback.metaTitle > core default. MTP route placeholders are resolved before merging; an empty or unresolvable value falls through. |
metaDescription | Per-URL MTP > category MTP > page seoFallback.metaDescription > core default. It uses the same placeholder and empty-value handling as title. |
metaRobots | Per-URL MTP > category MTP > page seoFallback.metaRobots > core default. Empty scalar MTP values fall through. |
canonical | Per-URL MTP > category MTP > page seoFallback.canonical > route-derived core canonical. An MTP value beginning with ? is resolved against the route-derived canonical. |
hreflang | Per-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. |
schemaMarkup | MTP 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. |
mediaScreenTag | Per-URL MTP > category MTP > core default. MTP hosts are rewritten to the configured desktop host. There is currently no page seoFallback field for it. |
pageCategory | Set 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)
- Fetch matcher config —
.../desktop/_category-matcher-config.jsonfrom the SEO CDN, via a 15-min in-memory cache. - Match the pathname — trie traversal against the config; no match → done, fall back to per-URL data only.
- 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 type | Matches | Example |
|---|---|---|
children (literal) | exact segment | hotel |
params: set | segment in a named value set | {culinary, coffee} |
params: regex | full-match regex (Java Pattern.matches semantics) | \d+ |
params: any | any segment | :slug |
wildcard | all 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:
- Split the pathname on
/and drop the locale segment (/en-id,/id-id, …) if present. - At each node, try edges for the current segment in precedence order: literal
childrenfirst, then eachparamsedge in config order (set/regex/any), thenwildcard. - 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.
- When all segments are consumed, the current node's
terminalis the result (noterminal→ no match at this depth, backtrack). wildcardis 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
| Step | State | What happens |
|---|---|---|
| 1 | pathname | Split → ["en-id", "activities", "culinary"]; en-id matches the locale pattern → segments = ["activities", "culinary"] |
| 2 | at root, segment activities | Literal child activities exists → descend |
| 3 | at activities node, segment culinary | Literal children: only search — no match. Param edges next: set edge → sets.activityTypes contains culinary → descend |
| 4 | at activityType node, segments exhausted | Node has terminal → return it |
| — | result | { categoryName: "activities-type", categoryId: 12 } |
Other inputs against the same config:
| Pathname | Result | Why |
|---|---|---|
/en-id/activities | activities-landing | Segments end on the activities node, which has a terminal |
/en-id/activities/search/a/b/c | activities-search | search literal, then wildcard swallows a/b/c |
/en-id/activities/12345 | activities-detail | Not in activityTypes set → backtrack to next param edge; regex \d+ full-matches 12345 |
/en-id/activities/random-slug | null | Not 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):
| Cache | File | TTL | Behavior |
|---|---|---|---|
| Matcher config | categoryMatcherConfigCache.ts | 15 min | Stale-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 data | categorySEODataCache.ts | 10 min | Simple 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 codesource: 'category'— category fetch,status='200'(data served),'404'(matched but no category file), or'error'(exception in the lookup)
File map
| File | Role |
|---|---|
getSEOData.ts | Entry point — orchestrates concurrent fetch + merge |
utils/lookupCategoryMatch.ts | Category pipeline: config → match → data |
utils/urlCategoryMatcher.ts | Pure trie matcher (pathname → category) |
utils/categoryMatcherConfigCache.ts | 15-min SWR cache for matcher config |
utils/categorySEODataCache.ts | 10-min TTL cache for category SEO data |
utils/fetchUrlSEOData.ts | Per-URL CDN fetch |
utils/mergeSEOData.ts | Three-layer field merge (url > category > default) |
utils/constructSEOData.ts | Builds the default SEO data from route/config |
SEOMetaRenderer.tsx | Renders the resolved SEO data into <head> |