SEO
How KDIGI CMS handles SEO: the placeholder-template model, the seo field contract, JSON-LD generation, Open Graph fallbacks, the audit and internal-link tools, optional AI suggestions, and slug/redirect handling.
The model is borrowed from Rank Math: titles and descriptions are templates resolved at render time, not stored. The CMS stores what editors type, generates breadcrumbs and a JSON-LD graph on save, and leaves the rest to the frontend. The API shape is in rest-api.md; the hook plumbing is in architecture.md.
Three tiers
Section titled “Three tiers”| Tier | Where | Who writes it |
|---|---|---|
| 1. Site templates | Sites.seoTitles.<subgroup>.{titleTemplate, descriptionTemplate} and Sites.seoTitles.separator | Admin, once per site |
| 2. Document override | seo.title / seo.description on each document | Editor. Empty = use the template |
| 3. Resolution | Frontend, at render time | Your frontend, using the same rules |
The CMS deliberately does not persist resolved values. makeApplySeoTemplatesHook (src/hooks/apply-seo-templates.ts) runs on every save of pages, posts, products, landings and taxonomies, but the only thing it writes is seo.breadcrumbs. Consequences:
- An empty
seo.titlein the API is correct, not missing data. - Changing a template in
Sites.seoTitlesaffects every document that has no override immediately — no re-save, no re-import. - Do not write scripts that "fill
seo.titlefor every post": they freeze values that should follow the template.
The site-form help text mentions that templates apply "on save"; the code does not do that — resolution is the frontend's job.
Resolving on the frontend
Section titled “Resolving on the frontend”The effective values follow this cascade (reference implementation: resolveSeoFields in src/hooks/apply-seo-templates.ts, resolveSeoTemplate in src/lib/seo/resolve-variables.ts):
title = seo.title || template(titleTemplate) || doc titledescription = seo.description || template(descriptionTemplate) || excerptog:title = seo.openGraph.title || titleog:description = seo.openGraph.description || descriptiontwitter:title = seo.openGraph.twitterMeta.title || (useFacebookData ? og:title : '')twitter:description= seo.openGraph.twitterMeta.description || (useFacebookData ? og:description : '')useFacebookData defaults to true.
Templates and variables
Section titled “Templates and variables”Sites.seoTitles has a separator (one of |, -, ·, », ~, —; default |) and six subgroups, each with a titleTemplate and a descriptionTemplate (not localized — they only contain variables, which resolve per locale):
| Subgroup | Default title | Default description | Used for |
|---|---|---|---|
homepage | %sitename% | %excerpt% | The front page |
posts | %title% %sep% %sitename% | %excerpt% | posts |
pages | %title% %sep% %sitename% | %excerpt% | pages |
products | %title% %sep% %sitename% | %excerpt% | products |
landings | %title% %sep% %sitename% | %excerpt% | landings |
taxonomies | %term% %sep% %sitename% | %excerpt% | taxonomies |
Variables (case-insensitive; an unknown or empty variable is dropped and the doubled/dangling separators it leaves are cleaned up):
| Variable | Value |
|---|---|
%title% | Document title |
%sitename% | Sites.knowledgeGraph.name → Sites.manifest.name → Sites.domain |
%sep% | The site's separator |
%excerpt% | Excerpt, HTML stripped |
%category% | First category title |
%focuskw% | First focus keyword |
%pt_single%, %pt_plural% | Post-type labels |
%date%, %modified% | YYYY-MM-DD of publish / modify date |
%term% | Taxonomy term name |
%currentyear% | Current year (supplied by the caller) |
%price% | Document price, when the caller supplies it |
The seo field group
Section titled “The seo field group”Defined once in src/fields/seo-field-group.ts and reused by pages, posts, products, landings and taxonomies. Field paths are a contract with frontends: renaming one, or changing the shape of jsonLd, breaks every consumer.
| Path | Type | Notes |
|---|---|---|
seo.title | text, localized | Empty → template |
seo.description | textarea, localized | Empty → template |
seo.focusKeywords | text, many | First one drives the score |
seo.openGraph.title, .description, .imageAlt | localized | |
seo.openGraph.image | upload → media | Filled at read time when empty, see OG image cascade |
seo.openGraph.siteName, .url, .type, .locale | text, hidden | type defaults to website |
seo.openGraph.twitterMeta.{useFacebookData, card, title, description, image} | group | Nested inside openGraph; card is summary or summary_large_image (default) |
seo.breadcrumbs[] | array { url, text (localized), isHidden } | Hidden in the admin UI but present in REST. Auto-generated, see below |
seo.breadcrumbTitle | text, localized, hidden | |
seo.canonicalOverride | text | Also used as the URL in the JSON-LD WebPage |
seo.robots | select, many | Default ['index','follow']; also allows max-snippet:-1, max-image-preview:large, max-video-preview:-1 |
seo.articleMeta.{publishedTime, modifiedTime} | dates, hidden | Top-level sibling of openGraph; written by the JSON-LD hook |
seo.updatedTime | date, hidden | Written by the JSON-LD hook |
seo.jsonLd.raw | textarea, read-only, hidden | An object with one string; see below |
seo.aiTitleBtn, aiDescBtn, openGraph.aiOgAltBtn, aiFaqBtn | ui | Buttons calling /api/seo/ai-generate |
seo.internalLinkSuggester, seo.seoAnalysis | ui | Client-side panels. The SEO score is never stored |
Breadcrumbs
Section titled “Breadcrumbs”seo.breadcrumbs is generated on save only when it is empty (a blank-only guard: editor-entered or imported breadcrumbs are never overwritten). Documents mapped as the front page (Sites.pageGroup key FRONT_PAGE) and documents without a slug are skipped.
The trail is [site name] + [primary taxonomy term and its parent chain] + [current document], all with isHidden: false. The first crumb's URL is https://{Sites.domain} and its text is the site name (falling back to "Trang chủ"). The leaf URL is https://{domain}/{slug}/, https://{domain}/product/{slug}/ for products, or seo.canonicalOverride when set. Which taxonomy supplies the middle crumb is set in Sites.breadcrumbs:
| Field | Default | Options |
|---|---|---|
primaryTaxonomyProduct | product-category | none, product-category, category, tag |
primaryTaxonomyPost | category | same |
The hook looks in the document's categories for a term whose type matches, then walks parent. Only posts and products get a middle crumb; pages, landings and taxonomy terms are flat.
JSON-LD
Section titled “JSON-LD”makeGenerateJsonLdHook(docType) (src/hooks/generate-jsonld.ts) runs as afterChange on pages, posts, products, landings and taxonomies. It builds one schema.org @graph and stores it in seo.jsonLd.raw.
What it persists: seo.jsonLd.raw, seo.articleMeta.{publishedTime, modifiedTime} and seo.updatedTime, in a single parameterised UPDATE of those four scalar columns (persistJsonLdRaw). It does not call payload.update, so there is no second version row and the other locale is untouched (architecture.md).
seo.jsonLd.raw is the full <script type="application/ld+json">…</script> element (serializeJsonLdScript in src/lib/jsonld/serialize.ts) that the frontend injects verbatim. Every < inside the payload is escaped as \u003c, so a </script> in a title cannot break out. Both the hook and any bulk regeneration must serialise through that module.
Graph contents
Section titled “Graph contents”All nodes live in a single @graph with stable @ids ({siteUrl}/#organization, {siteUrl}/#website, {pageUrl}#webpage, {pageUrl}#breadcrumb). siteUrl is https://{Sites.domain}; pageUrl is siteUrl/slug/ or seo.canonicalOverride.
| Document | Nodes (assembler in src/lib/jsonld/graph-assembler.ts) |
|---|---|
posts | BlogPosting, Person (author), WebPage, ImageObject, BreadcrumbList, WebSite, Organization, Place. FAQs from the document's faqs are nested in the BlogPosting as subjectOf |
pages | WebPage (ContactPage for slugs lien-he, contact, contact-us; AboutPage for gioi-thieu, about, about-us), ImageObject, BreadcrumbList, standalone FAQPage, site nodes, plus a LocalBusiness per Sites.knowledgeGraph.additionalOffices[] |
products | Product (with Offer and optional AggregateRating), ItemPage, ImageObject, BreadcrumbList, FAQPage, site nodes. URL is /product/{slug}/ |
landings | Same assembler as products |
taxonomies | CollectionPage, ImageObject, BreadcrumbList, site nodes |
Site nodes come from Sites (src/lib/jsonld/site-config-loader.ts), explicit fields first, legacy heuristics (contact, footer, branding) only when the explicit ones are blank:
| JSON-LD | Source |
|---|---|
Organization name / legal name / description | knowledgeGraph.name, .legalName, .description (name falls back to manifest.name, then the domain) |
Extra @type before Organization | knowledgeGraph.businessType: Organization, LocalBusiness, ProfessionalService, Store, Restaurant |
| Logo | knowledgeGraph.logo, else branding.logo |
telephone | knowledgeGraph.telephoneE164 (normalised on save by normalizeTelephoneE164), else telephone |
| Address, geo, opening hours | knowledgeGraph.address, .geo, .openingHours[] (emitted as OpeningHoursSpecification) |
foundingDate, founders, areaServed, knowsLanguage, contactPoint, priceRange, email | same-named knowledgeGraph fields |
sameAs | Sites.socialProfiles[].url, plus knowledgeGraph.wikidataId (as a Wikidata URL), wikipediaUrl, youtubeChannelUrl, tiktokUrl |
WebSite SearchAction | knowledgeGraph.searchActionUrl |
| Aggregate-rating threshold | knowledgeGraph.minReviewsForAggregate (default 10) |
If the site cannot be loaded the hook skips persisting rather than wiping an existing graph.
URL segments
Section titled “URL segments”Public paths used in the graph, in breadcrumbs and in redirects come from src/fields/slug-url-prefix-map.ts: products live under /product/, taxonomy type product-category under /product-category/, everything else (posts, pages, landings, taxonomy types category, tag, custom) at the site root. That map is a display/URL-building convention only — edit its tables if your frontend routes differently, and keep publicPathFor in src/lib/frontend-public-path.ts in step.
Product details
Section titled “Product details”offersis emitted only whenprices[0].currentPrice > 0; a missing or zero price logs a warning and omits the offer (no "free" offers).priceValidUntilcomes fromprices[0].validUntil; the seller is{siteUrl}/#organization.aggregateRatingis emitted only when the rating is not perfect (avg < 5) and eitheraggregateRatingOverrideis on, orcount >= minReviewsForAggregateand at least one approved local comment exists (commentCount > 0). Imported star ratings alone never produce it (src/lib/jsonld/product.ts).FAQPageentries need both a question and an answer; incomplete ones are dropped, and with none left the node is omitted.buildProductwritespriceCurrencyfrom the first price row'sprices[].currencyselect (defaultVND).
OG image cascade
Section titled “OG image cascade”Order: seo.openGraph.image → the document's featuredImage → Sites.seoDefaults.ogImage.
makeResolveOgImageFallbackHook (src/hooks/resolve-og-image-fallback.ts) applies this as an afterRead hook for REST reads only and never writes the fallback back, so the value always follows the current featured image and site default. A stored OG image that is merely a stale copy of the site default is replaced by the document's own featured image. Taxonomies have no featuredImage and fall back straight to the site default. The JSON-LD hook uses the same order, so <meta property="og:image"> and the graph agree.
Slugs and redirects
Section titled “Slugs and redirects”slugis auto-generated from the title when blank (lower-case,đ→d, diacritics stripped, non-alphanumerics →-). It is not localized and unique per site among non-trashed documents; a trashed document keeps its slug, and restoring it onto a slug now taken by another document fails with a bilingual field error (makeGuardRestoreSlugConflict).- Changing the slug of an existing document makes
autoRedirectOnSlugChangecreate aredirectsrow (301,isEnabled: true) from the old public path to the new one:/{slug}/for posts, pages and landings;/{segment}/{slug}/when the collection has a URL segment (products→product, fromsrc/fields/slug-url-prefix-map.ts). It skips if a redirect from that path already exists for the site, never blocks the save, and logs failures. Scripts that rename slugs outside the admin must create redirects themselves. - Redirect rules (
redirectscollection):fromis normalised to start with/,tomust be an internal path,fromandtocannot be equal; chains are only warned about. Your frontend applies them (rest-api.md). - The same save also queues a revalidate job for the old slug so its cached page is refreshed.
SEO audit
Section titled “SEO audit”GET /api/seo/audit (logged-in) scans posts, pages, products and landings of one site and returns the deficient documents, worst first.
| Param | Notes |
|---|---|
tenantId | Optional; honoured only for a super-admin or a member of that site |
limit | Documents inspected per collection; default 50, max 200 |
Site resolution: tenantId param → payload-tenant cookie (the admin's selected site, validated against membership) → the user's first site; none → 400. Response: { tenantId, total, deficient: [{ id, collection, title, score, reasons, adminUrl }] } (401 when not logged in, 500 on query failure).
A document is deficient when its stored seo.title or seo.description is blank, seo.robots contains noindex, or its score is below 50. reasons can contain missing-title, missing-desc, noindex, broken-canonical (a canonical override that does not start with http) and low-score:<n>. Because titles are normally left blank under the placeholder model, most documents will report missing-title/missing-desc unless editors set explicit values; read that as "no explicit override", not as "no title on the page".
The same audit powers the dashboard widget (src/components/admin/seo-audit-widget.tsx, via src/lib/seo-audit-core.ts).
computeSeoScore (src/lib/seo-score.ts) returns 0–100 plus a traffic-light checklist: focus keyword set / in title / in description / in slug / in the first 10% of the text / density (1–1.5% is good), title ≤ 60 characters, description ≤ 160, URL ≤ 75 characters, at least one internal Markdown link, and content-length tiers tuned for Vietnamese syllables. It runs in the editor and in the audit; it is not persisted.
Internal link suggestions
Section titled “Internal link suggestions”GET /api/seo/internal-link-suggest (logged-in) powers the editor's link suggester.
| Param | Notes |
|---|---|
docId, collection | Required; the current document is excluded |
keyword | Preferred token source |
title | Fallback token source |
limit | Default 5, max 10 |
It tokenises the keyword (or title) — lower-cased, diacritics stripped, tokens of 3+ characters — and scores up to 100 recent documents per collection (posts, pages, products, landings) of the caller's site: 2 points per title token match, 1 per slug token match. Results are { suggestions: [{ id, collection, title, slug, score, adminUrl, frontendSlug }] }, with frontendSlug built by publicPathFor (/product/{slug} for products, /{slug} otherwise). The site is the validated payload-tenant cookie or the user's first site (400 otherwise), and the query runs with the caller's own access rights.
AI SEO generation (optional)
Section titled “AI SEO generation (optional)”POST /api/seo/ai-generate (logged-in) returns a suggestion for one SEO field from an OpenAI-compatible chat API. It is optional: with no key configured the admin buttons still render, and the endpoint answers 502 {"error":"AI generation failed. Please try again."} and logs the configuration error.
| Env var | Meaning |
|---|---|
DEEPSEEK_API_KEY | API key. Required to enable |
DEEPSEEK_MODEL | Model id. Required, no default — pnpm verify:deepseek (src/scripts/verify-deepseek-model.ts) checks that a model answers in JSON mode |
SEO_AI_RATE_LIMIT | Calls per user per 60 s, default 20 |
The client uses the openai SDK with baseURL: 'https://api.deepseek.com' hard-coded in src/lib/ai/deepseek-client.ts (timeout 90 s, 3 SDK retries, JSON-object responses with one retry on a parse failure). There is no env var for the base URL; to use another OpenAI-compatible provider, change that file. The key never leaves the server. There is no boot warning for a missing key — the failure only shows up when someone clicks a generate button.
Request body:
| Field | Notes |
|---|---|
field | title, description, faq or alt |
docTitle | Required |
docContent | Optional plain-text excerpt, truncated to 600 characters |
focusKeyword | Optional |
imageContext | alt only |
count | faq only: 1–10, default 3 |
Response 200: { "suggestion": "…" }; for faq the suggestion is a JSON-stringified array of { question, answer }. Errors: 401 not logged in, 400 bad field/docTitle/JSON, 429 rate limit, 502 provider error or malformed answer. Prompts (src/lib/ai/seo-prompts.ts) are written in Vietnamese and target 40–65 characters for titles, 100–165 for descriptions and ≤ 125 for alt text.
Rate limiting is a per-process, in-memory rolling window keyed by the logged-in user id (never by anything in the request body); it resets on restart and is not shared between instances. The "Generate FAQ" button is preview-only: FAQs live in their own collection and the button cannot write there.
Stability contract and WordPress-migrated sites
Section titled “Stability contract and WordPress-migrated sites”Sites migrated from WordPress often rank on the strength of their existing markup, so the SEO surface is kept stable on purpose: the seoFieldGroup field paths, the seo.jsonLd.raw format, and the structure rules above (twitterMeta inside openGraph, articleMeta beside it, breadcrumbs with isHidden). The robots options include Rank Math's max-* directives so migrated values carry over verbatim, and the graph includes a Place node and per-office LocalBusiness nodes like Rank Math output. Treat any change to these as a breaking API change and roll it out together with your frontends. For the migration pipeline itself see wordpress-migration/README.md.