The header on matters.ai is composed at runtime by components/nav/Navbar.tsx.
It starts from a static seed in menu.config.ts and then
enriches three dropdowns with live data fetched from WordPress through internal API routes.
This page documents the full pipeline — how it works, how to publish/rename/hide an item, and the conventions marketing and engineering use to control the result.
WordPress (CPT or special category)
│ posts.title, posts.slug, posts.excerpt
▼
Next.js route handler ── /api/use-cases/nav (CPT: use_case)
- fetches list /api/industries/nav (CPT: industry)
- parses excerpt /api/case-studies/nav (special category: read_what_matters___case_studies)
- applies overrides
- sorts
│ { items: [{ title, slug, menuOrder }] }
▼
Navbar.tsx (client component)
- mounts with `staticNavigationMenu` (from menu.config.ts)
- useEffect per source → fetch → merge into the right dropdown
│
▼
Rendered <header> ┐
│ the user sees a hybrid menu — static
│ shape + dynamic children pulled live.
Three things matter:
Solutions → By Use Cases, Solutions → By Industries, and Customers.menu.config.ts)#menu.config.ts exports several MenuItem
constants and a navigationMenu array that the Navbar renders. Three of those constants get
their children replaced (Use Cases, Industries) or appended (Customers) at runtime — their
hardcoded children are placeholders or always-present links the static menu still needs.
| Constant | Purpose | Runtime behavior |
|---|---|---|
PLATFORM_MENU | Platform pages (What is Matters, How it Works, …) | Fully static. |
KEY_FEATURES_MENU | Key Features marketing pages | Fully static. |
PRODUCT_MENU | Product top-level | Fully static. |
SOLUTIONS_MENU | By Use Case (children[0]) + By Industry (children[1]) | Both subgroups' children are replaced at runtime with the live WP lists. The hardcoded URLs are dead. |
RESOURCES_MENU | Read What Matters + Resources | Fully static. |
CUSTOMERS_MENU | Customers / case studies | The runtime fetch appends WP case studies after whatever seeds already exist (deduped by href). |
COMPANY_MENU | Legal + careers + about | Fully static. |
The Navbar merges live items into SOLUTIONS_MENU.children[0] / children[1] and appends to CUSTOMERS_MENU directly, matched by title.
All three live under app/api/*/nav/route.ts. They follow the same
template:
fetch() to WordPress, requesting only title, slug, and excerpt.{ next: { revalidate, tags } }) so /api/revalidate can bust it.posts → items, parsing the excerpt for directives.menuOrder (asc; default 999).{ items: [...] } or, on any error, { items: [] }.| Route | WordPress source | Revalidate tag | Excerpt directives supported |
|---|---|---|---|
/api/use-cases/nav | CPT use_case | use-cases | [ORDER:n] |
/api/industries/nav | CPT industry | industries | [ORDER:n] |
/api/case-studies/nav | Posts in special category read_what_matters___case_studies | case-studies | [ORDER:n], [NAV_HIDE], [NAV_TITLE:...] |
Case studies live under a regular post category (one of the "special categories" prefixed with
read_what_matters___), not a custom post type. The route first looks up the category by slug, then queries posts filtered by its id. Use Cases and Industries use real WP custom post types (use_case,industry), so they fetch in a single call.
Authentication for the WordPress fetch differs by route. Industries and case-studies
use the shared getWPHeaders() helper (lib/api/wp-headers.ts), which sends Basic auth built
from WORDPRESS_USERNAME / WORDPRESS_APP_PASSWORD when those are set. Use-cases builds
the same Basic-auth header inline from process.env.WORDPRESS_USERNAME /
WORDPRESS_APP_PASSWORD rather than calling the helper. In all cases public posts work without
auth; the credentials only matter if a particular post is gated.
The cache TTL is not uniform: use-cases and industries cache for 4 hours
(revalidate: 60 * 60 * 4), while case-studies caches for 60 seconds (revalidate: 60).
The Navbar fetches over the client, so changes take effect either when the tag is busted
(WordPress webhook → /api/revalidate → revalidateTag(...)) or after the route's TTL elapses,
whichever comes first. See Content & CMS for the webhook side of the loop.
{
"items": [
{ "title": "Credit Saison India", "slug": "credit-saison", "menuOrder": 10 },
{ "title": "Groww", "slug": "groww", "menuOrder": 20 }
]
}
menuOrder is included in the response for debugging — the Navbar only reads title and slug.
These are typed into the WordPress post's Excerpt field. They are stripped before the title appears anywhere on the site, so they're safe to leave in. The route handler parses them with a regex, applies the rule, and removes the matched fragment before returning the title.
⚠️ Today the directive parser only runs in the case-studies route. Use Cases and Industries only honor
[ORDER:n]. Bringing the other two directives over is a one-file change if needed — see "Adding a new dynamic source" below for the template.
[ORDER:n] — sort key#[ORDER:1]
This is the post excerpt that visitors read…
n appears first in the dropdown.[ORDER] are sorted to the bottom (999).[NAV_HIDE] — exclude from navbar#[NAV_HIDE]
Excerpt content unchanged…
/case-studies/{slug}.[NAV_TITLE:Display Name] — override the displayed nav label#[NAV_TITLE:PayU]
Long descriptive case-study title for SEO and the page header.
All three directives are independent and can coexist in the same excerpt. Order doesn't matter:
[ORDER:1][NAV_TITLE:PayU]
Reducing data-exfiltration risk at scale: how PayU deployed Matters.AI across…
When you need to override what marketing put in WP without round-tripping through them,
add an entry to lib/case-study-nav-overrides.ts:
export const CASE_STUDY_NAV_OVERRIDES: Record<string, string | null> = {
"credit-saison": "Credit Saison India", // rename for the nav only
"payu-beta": null, // hide (same effect as [NAV_HIDE])
};
Precedence — highest first:
CASE_STUDY_NAV_OVERRIDES[slug] (null → hide; string → rename)[NAV_HIDE] excerpt directive[NAV_TITLE:...] excerpt directiveSort order has a code-level layer too. The case-studies route first honors an explicit
CASE_STUDY_NAV_ORDER array in lib/case-study-nav-overrides.ts
— currently ["credit-saison", "groww", "payu"] — placing any slug listed there in that order,
ahead of everything else. Items not in the array fall back to their [ORDER:n] excerpt
value (default 999).
components/nav/Navbar.tsx holds the
rendered menu in React state, initialized to staticNavigationMenu from menu.config.ts.
Three useEffect hooks run after mount, one per source. Each fetches its route, then runs a
targeted setNavigationMenu(prev => prev.map(...)) that locates the right dropdown by title
and either replaces or appends children.
| Effect | Target | Operation |
|---|---|---|
| Use Cases | Solutions → children[0] (By Use Case) | Replace the children with /use-case/{slug} links. |
| Industries | Solutions → children[1] (By Industry) | Replace the children with /industry/{slug} links. |
| Case Studies | Customers | Append /case-studies/{slug} links after the static seed, deduped by href (no double links). |
The Customers dedup guard exists because the static seed (CUSTOMERS_MENU in menu.config.ts)
already lists specific case studies — e.g. { title: "Credit Saison India", href: "/case-studies/credit-saison" } and { title: "Groww", href: "/case-studies/groww" }. The
runtime append would re-add those same /case-studies/{slug} links from WP, so each would
appear twice without the guard. The guard lives in Navbar.tsx's case-studies effect and uses
a Set of seen href values.
If any fetch fails, the effect catches silently — that dropdown just shows whatever seed was in place. The site never errors because the navbar is partially broken.
The card component components/blogs/BlogCard.tsx
detects whether a post belongs to the case-studies category and hides the author block when
it does. This affects every place a card is rendered — the case-studies listing
(/case-studies), the "You may also like" rail
on case-study detail pages (RecommendedBlogs.tsx),
and anywhere else case studies appear in card grids.
Detection uses the existing isUnderCategoryPath(slug, ["case-studies"]) helper from
lib/special-categories.ts,
so any post tagged under the special category — at any depth — is treated as a case study.
The date is still shown.
Rationale: case studies are co-authored by the customer and the matters.ai team; surfacing a single WP author on the card creates confusion and looks editorial rather than commercial.
Read What Matters – Case Studies (slug read_what_matters___case_studies).[ORDER:5] to position it in the Customers dropdown.[NAV_TITLE:Short Name] if the page title is too long for nav./case-studies/{slug}./case-studies shows the date but no author byline.Two paths, pick whichever fits the channel:
[NAV_TITLE:Better Name] to the excerpt."slug": "Better Name" to CASE_STUDY_NAV_OVERRIDES.The code path wins if both are set.
[NAV_HIDE] to the excerpt."slug": null to CASE_STUDY_NAV_OVERRIDES.The page remains reachable at /case-studies/{slug} and via search; only the navbar dropdown drops it.
The Navbar honors each route's cache TTL by default (4 hours for use-cases / industries, ~60s for case-studies). To force a refresh:
revalidateTag("case-studies") via the webhook or a redeploy.next dev (the in-memory cache resets on boot).To wire a new CPT or special category into a dropdown:
app/api/{your-source}/nav/route.ts. Copy the case-studies
handler verbatim if you need directive support, or the simpler industries handler if you
only need [ORDER:n]. Use a distinct tags: ["..."] so revalidation can target it.menu.config.ts by title, and
whether you're replacing children (like Use Cases / Industries) or appending them
(like Customers). Pick one strategy and stick with it — mixed merges are fragile.useEffects in Navbar.tsx. Match the menu
by title, run a setNavigationMenu(prev => prev.map(...)) that mutates only the targeted
subtree.lib/cache/revalidation.ts)
so edits to the new content type bust your new tag.| Path | Role |
|---|---|
menu.config.ts | Static seed for every dropdown. Where new always-present items go. |
components/nav/Navbar.tsx | Client component. Holds menu state, runs the three useEffect fetchers, renders the rendered header. |
app/api/use-cases/nav/route.ts | WP fetch + [ORDER:n] parser for Use Cases. |
app/api/industries/nav/route.ts | WP fetch + [ORDER:n] parser for Industries. |
app/api/case-studies/nav/route.ts | WP fetch + [ORDER:n] + [NAV_HIDE] + [NAV_TITLE:...] + code-override lookup for Case Studies. |
lib/case-study-nav-overrides.ts | Engineering-side slug → label map. null to hide. |
lib/special-categories.ts | Special-category prefix detection and path matching used by both routes and BlogCard. |
lib/api/wp-headers.ts | Shared getWPHeaders() that builds Basic auth from WORDPRESS_USERNAME / WORDPRESS_APP_PASSWORD. |
lib/cache/revalidation.ts | Maps WordPress webhook payloads to cache tags. New nav source must register here. |
components/blogs/BlogCard.tsx | Card renderer. Detects case-study category and hides the author block. |
components/blogs/RecommendedBlogs.tsx | "You may also like" rail. Uses BlogCard — inherits the author-hide behavior automatically. |
| Symptom | Cause | Fix |
|---|---|---|
| Dropdown shows the static seed only, dynamic items missing | Route returned { items: [] } — most often the WP fetch failed. | Hit the route directly (/api/case-studies/nav) and inspect; check WORDPRESS_URL and auth env vars. |
| A case study published in WP isn't showing up | (a) Excerpt contains [NAV_HIDE]. (b) Slug is in CASE_STUDY_NAV_OVERRIDES with null. (c) Cache hasn't busted. | Check excerpt, check the overrides file, then revalidateTag("case-studies") or wait up to ~60s. |
| Item shows twice in the Customers dropdown | Dedup guard removed or static seed url collides with /case-studies/{slug}. | Restore the dedup Set in Navbar.tsx case-studies effect; rename the colliding static seed. |
Wrong order — [ORDER:n] ignored | Excerpt is in rendered HTML form and got mangled (e.g. [ORDER:1] became <p>[ORDER:1]</p>). The regex tolerates this. | Confirm the directive is present in excerpt.rendered; if WP is stripping brackets, escape or move the directive to the excerpt's top line. |
| Author byline still appears on a case-study card | The post isn't actually assigned to the case-studies special category. | In WordPress, assign the post to Read What Matters – Case Studies. The check is category-based, not URL-based. |
CASE_STUDY_NAV_OVERRIDES change isn't visible | Code overrides only take effect on next deploy (the file is read at request time, but the deployed bundle is what runs). | Redeploy or restart next dev. |
Customers overview, the Solutions parent itself) that should never depend on WP being reachable.BlogCard so every
surface inherits it.