The public site is the marketing surface — SEO matters. This page documents the conventions and the surface area that affects discoverability.
Every public page exports a Next.js Metadata object with title, description, OG image,
canonical URL, robots, and (where relevant) Twitter card. Two patterns:
export const metadata: Metadata = { … }.[slug] params export
export async function generateMetadata({ params }) { … } and pull title/description from
the same data source the page renders (WordPress or MongoDB), so SEO and content never
drift.Helper utilities for building consistent metadata and structured data live in
lib/seo-config.ts:
coreMetadata — shared base metadata (titles, OG defaults) merged into page metadata.generatePageMetadata({ … }) — builds a Next.js Metadata object for a page.StructuredData — React component that renders a JSON-LD <script> for a given schema.generateBreadcrumbSchema(...), generateFAQSchema(...), plus the prebuilt
organizationSchema, websiteSchema, softwareApplicationSchema, productSchema,
localBusinessSchema, brandSchema, jobPostingSchema, and more.Content-specific schema lives alongside the data layer: lib/post-schema.ts (blog posts),
lib/faq.ts (FAQ data), and lib/glossary/schema-org.ts (glossary terms).
lib/seo/ holds:
lib/seo/indexnow.ts — IndexNow ping helper.lib/seo/sitemap.ts — shared XML sitemap primitives and the lastmod contract.lib/seo/sitemap-freshness.ts — per-section "newest content date" resolvers used by the
sitemap index.JSON-LD is injected via the StructuredData component from lib/seo-config.ts (which renders a
<script type="application/ld+json">). Blog posts additionally use the RankMathJsonLd
component to pass through Rank Math SEO markup from WordPress. The shapes we emit:
| Shape | Where |
|---|---|
Organization | Root layout (every page), via StructuredData schema={organizationSchema}. |
WebSite + SearchAction | Root layout (websiteSchema). |
SoftwareApplication, Product, LocalBusiness, Brand | Root layout (prebuilt schemas in lib/seo-config.ts). |
BlogPosting + BreadcrumbList | Blog posts, generated by buildPostExtraJsonLd in lib/post-schema.ts. |
Article | Glossary terms (lib/glossary/schema-org.ts). |
FAQPage | Glossary terms, FAQ sections (generateFAQSchema, glossary buildFaqJsonLd). |
DefinedTerm + DefinedTermSet | Glossary terms / glossary index (lib/glossary/schema-org.ts). |
BreadcrumbList | Any page with a parent (uses generateBreadcrumbSchema in lib/seo-config.ts; glossary emits its own). |
JobPosting | /careers/* job pages (jobPostingSchema). |
Validate at https://search.google.com/test/rich-results before shipping changes.
All permanent (308) redirects are declared in the redirects() function in
next.config.ts. Next.js applies the first matching rule, so specific
paths must precede catch-alls. Add a redirect here whenever a URL is renamed, removed, or
linked from elsewhere with a path the app doesn't serve.
Notable groups:
/workspace/* and /auth/* → the workspace subdomain (gated on
WORKSPACE_SUBDOMAIN_URL)./key-features/* and product renames — e.g. /dspm → /data-security-intelligence,
/key-features/ai-data-detection-response (and the advanced- alias) → /ai-data-detection-response./solutions/by-use-case/* and
/solutions/by-industry/* structure that was never built, which Ahrefs flagged as the
dominant 404 source (each link repeated across ~179 pages). The nav copy is intentionally
retained; the dead paths redirect to live destinations instead:
/solutions/by-industry/finance → /industry/finance, /solutions/by-industry/healthcare
→ /industry/healthcare (the only two industries with dedicated pages)./solutions/by-industry/:slug* (manufacturing, retail-travel, insurance, section landing)
→ /industries./solutions/by-use-case/:slug* and /solutions → /use-cases (label→slug mapping is
fuzzy, so the index is the safe target)./compliance/gdpr and /compliance/hipaa → /compliance (no dedicated pages yet).Canonicalization. Every indexable page should emit a self-referential
alternates.canonical. Query-string variants of the same page must point at the bare path so
search engines consolidate them — Ahrefs flagged the careers application form
(/careers/apply?id=…&role=…) as duplicate content without a canonical. Because that page is a
Client Component (it reads the query string), the canonical is set in a sibling server layout,
app/careers/apply/layout.tsx, which pins
canonical: "/careers/apply" for every variant.
app/(sitemaps)/* — the sitemap route handlers. The index is sitemap-index.xml, and it
references 13 children:
/sitemap.xml (the framework-native root sitemap, see below), plus
sitemap-authors.xml, sitemap-blogs.xml, sitemap-careers.xml,
sitemap-datasheets.xml, sitemap-glossary.xml, sitemap-industries.xml,
sitemap-key-features.xml, sitemap-news.xml, sitemap-platform.xml,
sitemap-use-cases.xml, sitemap-videos.xml, sitemap-webinars.xml.sitemap_index.xml (underscore), sitemap-pages.xml, or
sitemap-posts.xml. The underscore variant is the Yoast/Rank Math default — if it is
what is registered in Search Console, that sitemap will report as failing. The correct
URL to submit is https://www.matters.ai/sitemap-index.xml.app/sitemap.ts — the Next.js root static sitemap (/sitemap.xml), covering 18
hand-authored marketing, legal, compliance and listing routes.app/robots.txt — a static robots.txt (not a generated robots.ts). It Disallows
/api/, /admin/, /tools/, /workspace/, /_next/, /examples/, plus honeypot paths.
A per-UA Allow is set for GPTBot, ChatGPT-User, Claude-Web, and Google-Extended; the
default User-agent: * is Allow: /. The Sitemap: directive points at
sitemap-index.xml. AI bots are welcome (we want to be cited).lastmod contract — read before touching a sitemap#<lastmod> must describe when the content changed, never when the sitemap was requested.
This is not a style preference. Until WEB-94, every sitemap emitted
new Date().toISOString() at request time, so two fetches seconds apart returned different
timestamps for all 248 URLs:
<lastmod>2026-07-29T13:26:13.208Z</lastmod> ← request 1
<lastmod>2026-07-29T13:26:18.837Z</lastmod> ← request 2, 5s later
Google detects that a lastmod is unreliable, stops trusting it for the whole property,
and falls back to its own recrawl heuristics. That is a primary cause of freshly published
posts sitting in "Discovered – currently not indexed".
Rules, enforced through lib/seo/sitemap.ts:
modified /
updated_at / date, resolved with toIsoTimestamp()./blogs is as fresh as the newest post on
it; /use-cases as the newest use case; /glossary as the newest term. Use
latestTimestamp().STATIC_ROUTE_REVISION — a constant exported from
lib/seo/sitemap.ts. Bump it when you edit that copy. A stale constant is merely
uninformative; a moving one destroys trust in the entire sitemap.STATIC_ROUTE_REVISION, so a WordPress hiccup cannot masquerade as a site-wide edit.sitemap-index.xml asks
lib/seo/sitemap-freshness.ts for each section's newest content date.lib/seo/sitemap.ts)#| Export | Purpose |
|---|---|
STATIC_ROUTE_REVISION | Stable stamp for routes with no CMS record. Bump on copy edits. |
toIsoTimestamp(value, fallback?) | Safe date → ISO. Returns the fallback, never "now". |
latestTimestamp(values, fallback?) | Newest of a set — for rolling children into a parent. |
latestEntryTimestamp(entries, fallback?) | Same, over SitemapEntry[]. |
buildUrlsetXml(entries) | Renders <urlset>. Single source for all children. |
buildSitemapIndexXml(entries) | Renders <sitemapindex>. |
dedupeByUrl(entries) | Merges duplicate URLs, keeping the newest date. |
xmlResponse(xml, cacheControl) | Response with Content-Type: application/xml. |
escapeXml(value) | The five XML predefined entities. |
<changefreq> and <priority> are deliberately no longer emitted — Google ignores both,
and so does Bing. They only added bytes and another value that could drift.
lib/seo/sitemap-freshness.ts)#The index needs one date per child, not the child's whole entry list, so each resolver asks
its source for the single newest record (per_page=1&orderby=modified, or sort().limit(1)).
Every resolver is fault-isolated and degrades to STATIC_ROUTE_REVISION.
getBlogsFreshness() also backs the news, videos and authors sitemaps — all three
are projections of the same post corpus, so they turn over exactly when it does.
Note that sitemap-news.xml and sitemap-videos.xml carry no <lastmod> at all: they use
<news:publication_date> and <video:publication_date> respectively, both sourced from real
post dates.
/blogs/[slug] is the highest-volume indexable surface, and WEB-94 found four defects there
that together kept new posts out of the index. The rules below are the fix; keep them intact.
| Situation | Response | Why |
|---|---|---|
| No such post | 404 via notFound() | Tells Google the URL is not a page. |
Post is in the private category, on production | 404 via notFound() | Preview content must look non-existent, not merely empty. |
| WordPress timed out or 5xx'd | 5xx — the error bubbles | Google retries later and keeps the existing index entry. |
The failure mode this replaced: app/blogs/[slug]/page.tsx caught everything and returned
"Post Not Found" JSX, so Next.js answered 200 OK with robots: index, follow and a canonical
pointing at /blogs. Google logged soft 404s. Worse, because the same catch swallowed
transient upstream errors, a live post got that treatment whenever WordPress wobbled during a
crawl — which was near-guaranteed given the page was uncached and hit WordPress on every hit.
Two invariants follow:
try/catch. notFound() and
permanentRedirect() signal through thrown control-flow errors; catching broadly breaks
both. Narrow .catch() on genuinely optional data (e.g. recommended posts) is fine.getPostBySlug in lib/wordpress.ts returns null only for "no such post" and throws
on upstream failure. Never "simplify" that catch back into return null — that single
line is what conflated a WordPress outage with a deleted post.private category)#Posts tagged with the WordPress private category are drafts/beta content that WP still
reports as status: publish. They are hidden on production in three places:
getPostBySlug returns null when isProductionEnvironment() — so the page 404s.filterPrivateContent drops them from listings.sitemap-blogs.xml, sitemap-news.xml and sitemap-videos.xml pass categories_exclude.Outside production they render normally, which is the point — the category is the preview mechanism.
The private check inspects every category on the post, via the cached
getPrivateCategoryId(). It previously looked only at categories[0], so a post tagged
[Announcement, private] leaked onto production while [private, Announcement] did not.
/blogs/[slug] is ISR, not force-dynamic. Under force-dynamic every response carried
cache-control: private, no-cache, no-store and missed the CDN on every single request, so
each crawl triggered a live WordPress REST + GraphQL round trip.
The effective revalidate is the minimum of the route's revalidate and the revalidate on
any fetch performed during render. wordpressFetch uses getCacheDuration(), driven by
WORDPRESS_CACHE_DURATION (currently 60), so that env var — not the export const revalidate in the page — sets the real CDN TTL.
generateStaticParams deliberately returns []. Prerendering the whole corpus would couple
the deploy to ~75 sequential WordPress calls, and since getPostBySlug now correctly throws
on upstream failure, one flaky response would fail the build. Pages generate on first request
instead.
Publishes do not wait for expiry — the WordPress webhook at /api/revalidate calls
revalidatePath('/blogs/[slug]') (lib/cache/revalidation.ts).
app/blogs/[slug]/not-found.tsx exists purely to fix metadata. app/blogs/layout.tsx declares
a blanket canonical: "/blogs" — it must, because the /blogs listing is a Client Component
and cannot export metadata — and every descendant inherits it, including the 404. Declaring a
missing post to be a duplicate of the blog index is exactly the soft-404 signal we removed, so
that file clears the canonical and pins robots: { index: false, follow: false }.
| Tool | Purpose | Where it's loaded |
|---|---|---|
| Google Analytics 4 | Page views, conversion events | components/ConsentAwareAnalytics.tsx (consent-gated) |
| Microsoft Clarity | Session replays, heatmaps | Same, via NEXT_PUBLIC_MICROSOFT_CLARITY_ID |
| Vercel Analytics | Edge / Web Vitals | components/ConsentAwareAnalytics.tsx (consent-gated; the raw @vercel/analytics import in app/layout.tsx is commented out) |
| Sentry | Errors + performance | Production-only, gated via shouldEnableSentry() |
| rb2b | Anonymous visitor identification | Same, via NEXT_PUBLIC_RB2B_* |
| Frase | AI-crawler visit tracking | proxy.ts — fires for known bot UAs |
All client-side analytics are gated by the consent manager
(components/privacy/ConsentManager.tsx). Server-side telemetry (Sentry) is always on in prod.
The site is deliberately hospitable to AI crawlers:
robots.txt sets explicit Allow rules for GPTBot, ChatGPT-User, Claude-Web, and
Google-Extended (and User-agent: * is Allow: /, so other crawlers are permitted too).proxy.ts logs every AI-bot visit to Frase so we can measure citation rate.If a future product change needs to opt OUT of AI ingestion on specific pages, set
<meta name="robots" content="noai, noimageai"> in the page's metadata.
Track in PageSpeed Insights (PAGESPEED_API_KEY env) and Search Console field data
(GSC_SITE_URL).