Notifio is a desktop app that watches rental listing search pages and alerts you when a new listing appears. Its site has eight long-form pages, split across two URL spaces for a reason I still think is right: /guides/<task> for somebody describing a job to be done, /for/<person> for somebody describing themselves.
The type that both routes render says so in its own doc comment:
/**
* The shape of the long-form pages: /for/[audience] and /guides/[slug].
*
* They are separate URL spaces because they serve different intent: /for/ pages
* are for somebody describing themselves, /guides/ for somebody describing a
* task.
*/
Then I tried to visit the second of those spaces.
$ curl -o /dev/null -w "%{http_code}\n" https://notifio.app/for
404
$ curl -o /dev/null -w "%{http_code}\n" https://notifio.app/guides
200
What is actually on disk
server/src/app/for/
└── [audience]/
└── page.tsx
One dynamic route, no page.tsx at the segment root. In the App Router that is not an error, it is just a path with no page, so Next.js answers the not-found. The three pages under it are completely fine: /for/students, /for/expats and /for/amsterdam all return 200, all carry their own canonical, and all appear in the sitemap, because the sitemap is generated from the article catalogue rather than typed out:
const articlePages: MetadataRoute.Sitemap = ALL_ARTICLES.map((article) => ({
url: `${APP_URL}${articleHref(article)}`,
// ...
}));
ALL_ARTICLES is the three audience pages plus the five guides, and articleHref is the only thing that knows which prefix each one takes:
export function articleHref(article: Article): string {
return article.kind === "audience" ? `/for/${article.slug}` : `/guides/${article.slug}`;
}
So all 38 URLs in the sitemap are real. The gap is not an indexing problem. It is that a reader who deletes the last segment of a URL, which is a thing readers genuinely do, lands on a 404 on one half of our long-form content and on a hub on the other.
The workaround, which has been sitting in the layout the whole time
Both routes share one component, and that component builds the breadcrumb trail. Here it is, verbatim:
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 },
];
The two branches of that ternary are byte-identical. I wrote that conditional intending to send audience pages to their own index, discovered there was no index to send them to, and filled both arms with the same object rather than deleting the condition. Nothing is broken by it. TypeScript is content, the linter has no rule for it, and the rendered breadcrumb is correct.
What it is, is a comment written in syntax instead of English. Every line of it says "this is where the audience hub would go", and because it says it in code rather than in a TODO, it never appeared in a list of anything.
The breadcrumb it produces is live in the structured data on each audience page:
"itemListElement": [
{"position":1,"name":"Home","item":"https://notifio.app/"},
{"position":2,"name":"Guides","item":"https://notifio.app/guides"},
{"position":3,"name":"For students"}
]
Which is not a lie, and that is the saving grace here: /guides really is the parent, because it lists all eight pages under two headings, "How the market works, and how to be early" for the five guides and "If this is your situation" for the three audience pages. One hub for two URL spaces, named after one of them.
Counting the links they actually have
An orphan page is one nothing links to. I wanted to know whether these three were orphans in practice, so I fetched 21 pages of the live site and counted every href resolving to each of them.
const hrefs = [...document.querySelectorAll("a")].map((a) => a.getAttribute("href"));
| Page | Inbound links across 21 pages |
|---|---|
| /for/students | 3 |
| /for/expats | 4 |
| /for/amsterdam | 4 |
Not orphans. But look at where those links come from: one each from /guides, and the rest from the related-article lists at the foot of other articles. Zero from the nav, which carries four links, and zero from the footer, which carries nine. The three audience pages are reachable only from the content, never from the chrome, on every one of the 38 pages in our sitemap.
That is a thinner thread than I would have guessed from having written the thing.
The graph underneath those links
The related lists are not computed, they are declared. Each article names three others by slug:
relatedArticles: ["first-message-to-a-landlord", "rental-scams-netherlands", "amsterdam"],
Eight articles times three is 24 edges, and since they are hand-written, it is worth asking what shape they came out as. Counting:
- 24 edges, no page with fewer than three outbound. Guaranteed by hand, not by the type, which would accept an empty array.
- In-degree runs from 2 to 5. Nothing is unlinked. /guides/first-message-to-a-landlord is named by five of the other seven, which is a fair verdict on what people actually need next: the whole product is about being early, and being early is worth nothing if the message you send is bad.
- 9 mutual pairs and 6 one-way edges. The one-way ones all point at that same page and at the scams and documents pages, meaning the graph slopes toward the three pages with concrete, checkable advice.
- 9 of the 24 edges cross the guide/audience boundary. The split into two URL spaces was an intent distinction, and the links ignore it almost 40 percent of the time, which is the behaviour you want. A student reading about scams should be offered the scams guide, not three more student pages.
The resolution step is where I would flag a design risk:
export function relatedArticles(article: Article): Article[] {
return article.relatedArticles
.map(getArticle)
.filter((other): other is Article => other !== undefined && other.slug !== article.slug);
}
Slugs are strings, getArticle returns undefined for an unknown one, and the filter drops it silently. Mistype one and the page quietly shows two related articles instead of three, forever, with a green build. The self-link guard right next to it tells you somebody already hit the neighbouring version of this problem.
That is the real cost of referencing content by string key: the compiler cannot help you, so something else has to. A test asserting that every slug in every relatedArticles resolves is five lines and closes it, and unlike the missing index page, nothing about it is a judgement call.
What I am changing
- Add
app/for/page.tsx. It is the same component as/guides, scoped toAUDIENCE_PAGES, and it turns a 404 into a page. - Then delete the ternary, because the audience arm finally has somewhere real to point.
- Add the slug-resolution test.
- Leave the nav at four links. The nav is for people deciding whether to buy, and the right link for that is /download or /pricing, not an essay. The footer is where a content hub belongs.
Takeaways
- A dynamic segment with no page at its root is a 404 your own URL structure invites people to find. Check the parent of every dynamic route by hand; nothing in the build will.
- A conditional whose branches are identical is a
TODOthat cannot be grepped. If you cannot implement the branch, leave a comment, not a dead ternary. - Count inbound links from the rendered HTML before calling a page well linked. "It is in the sitemap" and "a reader can find it" are different claims.
- Referencing content by string key puts the typo class of bug outside the type system. Buy it back with one test that resolves every key.
The app all of this is written around is on the download page, and the alerts hub is the other generated URL space on the site, which does have an index.
Top comments (0)