There is a ternary in ArticleLayout.tsx that picks between two identical values:
const crumbs: Crumb[] = [
{ label: "Home", href: "/" },
article.kind === "audience"
? { label: "Guides", href: "/guides" }
: { label: "Guides", href: "/guides" },
{ label: article.eyebrow === "Guide" ? article.h1 : article.eyebrow },
];
My first reaction was that this is a half-finished edit. It is not, quite. It is the shape of a decision that turned out to have one answer, left in the code as though it still had two, and the reason it has one answer is the most interesting thing about this part of the site.
Two routes, one document
Notifio has eight long-form pages across two URL spaces. Five guides like /guides/how-fast-do-rental-listings-go, and three audience pages like /for/students. They are the same type:
/**
* The shape of the long-form pages: /for/[audience] and /guides/[slug].
*
* Both routes render the same structure, because they are the same kind of
* document — a person with a specific problem, a page that answers it, and a
* product mentioned where it is genuinely part of the answer and not before.
* They are separate URL spaces because they serve different intent: /for/ pages
* are for somebody describing themselves, /guides/ for somebody describing a
* task.
*/
That last sentence is the whole justification for the split. "I am a student looking for a room" and "how do I write a first message to a landlord" are different searches by different people in different moods, and a URL that matches the shape of the question is worth having even when the page behind it is built identically.
The type carries one field to tell them apart:
/** Which route this belongs to. */
kind: "audience" | "guide";
And both route files are 40 lines of the same thing:
export const dynamic = "force-static";
export function generateStaticParams() {
return AUDIENCE_PAGES.map((page) => ({ audience: page.slug }));
}
The related links do not respect the split at all
Here is where the two URL spaces stop being two of anything. Every article lists three related articles by slug, and every single one of those lists crosses the boundary:
// in guides.ts
relatedArticles: ["how-to-be-first-to-a-rental-listing", "first-message-to-a-landlord", "amsterdam"],
// in audiences.ts
relatedArticles: ["students", "expats", "how-fast-do-rental-listings-go"],
"amsterdam" is an audience page. "how-fast-do-rental-listings-go" is a guide. Nothing in the data says which, and the author writing the list does not have to care. 24 related-article references across 8 articles, resolved by one flat lookup over the union:
export const ALL_ARTICLES: readonly Article[] = [...AUDIENCE_PAGES, ...GUIDE_PAGES];
export function getArticle(slug: string): Article | undefined {
return ALL_ARTICLES.find((article) => article.slug === slug);
}
/** The route an article lives on, so cross-links between the two work. */
export function articleHref(article: Article): string {
return article.kind === "audience" ? `/for/${article.slug}` : `/guides/${article.slug}`;
}
So the split is real in the URL and absent in the content graph. That is the right way round: the reader navigating between two related pages does not care which collection either of them is in, and the only thing that has to know is the function that builds an href.
There is an unenforced constraint hiding in getArticle, worth writing down before it bites. The two arrays share one slug namespace. If guides.ts and audiences.ts both contained a slug amsterdam, both pages would still build and both would still be reachable directly, because each route searches only its own array. But getArticle spreads audiences first, so every related-article link to amsterdam anywhere on the site would resolve to the audience page and the guide would become unlinkable. Nothing in the type system or the tests catches that. It is one assert away from being caught, and it is on the list.
Why /guides is the index for both
The breadcrumb ternary is identical because notifio.app/guides is the index page for all eight articles, not for the five that live under /guides/. Its structured data is built from the union:
itemListElement: ALL_ARTICLES.map((article, i) => ({ ... }))
and it renders the two collections as two sections on one page. So the live page links to /for/students and /for/expats and /for/amsterdam alongside the guides. There is no /for index and there is not going to be one, because three pages do not need a hub and splitting the hub would split the internal linking that makes either half worth having.
Which means an audience page's parent crumb genuinely is /guides, and the ternary is not wrong, it is just pretending to be a choice. You can check it: the BreadcrumbList on /for/students is
{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://notifio.app/" },
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://notifio.app/guides" },
{ "@type": "ListItem", "position": 3, "name": "For students" }
]
}
and the page it describes is canonically /for/students. A breadcrumb that crosses URL prefixes looks wrong at a glance and is correct here, which is exactly the case where a collapsed ternary is harmful: it reads as unfinished work rather than as a settled answer. The fix is to collapse it to one object and put the sentence above it, so the next person does not reinstate a branch that has nowhere to go.
The last crumb is keyed on a display string
{ label: article.eyebrow === "Guide" ? article.h1 : article.eyebrow }
eyebrow is the small label above the H1. All five guides have eyebrow: "Guide", and the three audience pages have "For students", "For people relocating" and "For Amsterdam". So guides get their full H1 as the final crumb and audience pages get their eyebrow, which produces the right result for both: "How fast do rental listings actually disappear?" against "Guide" would be an uninformative crumb, and "For students" is already the name of the thing.
It works, and it is comparing copy against a string literal to decide document structure. Renaming an eyebrow from "Guide" to "Guides" silently changes the breadcrumb on five pages. The field that should drive this is kind, which exists, is already a discriminated union, and is already used two lines earlier. That one is a straightforward fix.
The canonical is passed in, not derived
The layout takes the URL rather than computing it:
/**
* The `href` argument is passed in rather than derived here so that the
* canonical URL and the structured data always agree with the route that
* actually served the page.
*/
export default function ArticleLayout({ article, href }: { article: Article; href: string }) {
Both route files pass the same string they used in generateMetadata:
alternates: { canonical: `/for/${article.slug}` },
// ...
return <ArticleLayout article={article} href={`/for/${article.slug}`} />;
Deriving it inside the layout with articleHref() would work today and would be a second source of truth. The route knows what URL it is serving. The layout does not. An Article node whose @id and mainEntityOfPage were computed from a field in the data, while the canonical came from the route, is the sort of disagreement that produces a Search Console message six weeks later with no obvious cause.
Four nodes come out of it, all keyed to that one string:
"@graph": [
breadcrumbListLd(crumbs, APP_URL),
softwareApplicationLd(),
{ "@type": "Article", "@id": `${APP_URL}${href}#article`, ... },
{ "@type": "FAQPage", "@id": `${APP_URL}${href}#faq`, ... },
]
softwareApplicationLd() is the one that does not take the href, deliberately:
/**
* `@id` is stable so that every page emitting this node merges into one entity
* rather than declaring a new application per URL. No `aggregateRating`: we do
* not collect ratings, and inventing one is a manual action waiting to happen.
*/
One silent failure, flagged
The layout turns relatedSites slugs into links to the per-site alert pages:
const sites = article.relatedSites
.map(getAlertSite)
.filter((site): site is NonNullable<ReturnType<typeof getAlertSite>> => site !== undefined);
A typo in a slug produces undefined, gets filtered, and the section renders one card short. No error, no warning, nothing in the build output. That is the correct runtime behaviour, because a missing related link should not take a page down, and it is the wrong build behaviour, because the whole point of keeping this content in TypeScript is that mistakes are supposed to be loud. The right place for that check is the test suite that already walks these collections for title and description length, which is one more assert in a loop it already runs.
The editorial rule underneath all of it
None of the above matters if the pages are not worth reading, and the type file says what they have to be:
/**
* The rule these pages are written against: the advice has to be worth reading
* by somebody who never buys anything. A guide that is a product pitch with
* headings does not earn links, does not get read twice, and is obvious.
*/
The clearest test of whether that holds is the paragraph in /for/students that argues against the product:
One caveat worth knowing before you rely on it: Notifio runs on your laptop, so it only watches while the machine is on and awake. During a September search that is usually fine, because you are at the laptop anyway. If you are searching from a phone in another country, a cloud service suits you better and we say so on the comparison pages.
That is in the section that introduces the product, not in a footnote. Same instinct as the required whereTheyWin field on the comparison pages, which I wrote about in Our page about a competitor opens by telling you to go and fix their settings.
What transfers
- Split URLs by reader intent, and do not split the content graph with them. Two prefixes, one type, one union, one href function. The person writing a related-links list should not have to know which collection a slug is in.
-
Pass the serving route's URL down rather than recomputing it. Canonical,
@idandmainEntityOfPageall deriving from one argument is cheap insurance against a disagreement nobody notices. -
Branch on the discriminator, not on copy.
kindexists for this.eyebrow === "Guide"works until an editor renames a label. - A ternary with identical branches is documentation debt. It tells the next reader there is a decision here when there is not. Collapse it and write the sentence.
-
If you keep content in a typed module, make the mistakes fail the build. A filtered
undefinedis the right runtime behaviour and a missed opportunity at build time.
The output is eight pages: the index is notifio.app/guides, and the two deepest ones worth reading on their own are how fast do rental listings go and renting in Amsterdam.
Top comments (0)