Notifio's long-form pages live at two URL prefixes. /for/students, /for/expats, /for/amsterdam. And /guides/how-to-be-first-to-a-rental-listing, /guides/first-message-to-a-landlord, /guides/rental-scams-netherlands.
Structurally they are the same document. Same fields, same layout component, same structured data, same everything. A reasonable reviewer would ask why they are not one route with one prefix, and the answer is the only thing in this post worth arguing about.
They are split on intent, not on shape
/**
* 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.
*/
Somebody types "room hunting as a student in the Netherlands". They are describing who they are. Somebody else types "how do I message a landlord first". They are describing a job they need done. Those are different searches and they deserve URLs that read like the thing the reader asked for.
/for/students tells a person who they are about to be spoken to as. /guides/first-message-to-a-landlord tells them what they are about to be taught. Flatten both into /articles/ and every URL becomes a slug you have to read to the end before you know which kind of page you are getting.
This is not a ranking trick. It is the same reason a bookshop separates "for beginners" from "how to". Same paper, different question in the reader's head.
One type, two collections, and no page reachable twice
The type carries the distinction as a field:
export type Article = {
slug: string;
/** Which route this belongs to. */
kind: "audience" | "guide";
/** Small label above the H1. */
eyebrow: string;
title: string;
description: string;
h1: string;
standfirst: string;
/** Rough minutes, shown under the title. Keep it honest. */
readingMinutes: number;
sections: ArticleSection[];
faqs: { q: string; a: string }[];
sources: ArticleSource[];
/** Slugs of /alerts/<slug> pages worth linking from here. */
relatedSites: string[];
/** Slugs of other articles, any kind. */
relatedArticles: string[];
};
But each route resolves against its own collection, not against the union:
export function generateStaticParams() {
return GUIDE_PAGES.map((page) => ({ slug: page.slug }));
}
const article = GUIDE_PAGES.find((page) => page.slug === slug);
if (!article) notFound();
That is the detail that makes two URL spaces safe. /for/first-message-to-a-landlord is a 404, not a second copy of a guide. If both routes searched ALL_ARTICLES, every document would be reachable at two URLs, and I would have built a duplicate content problem out of a naming preference. The canonical tag would be arguing with a route that genuinely serves 200s, and canonical tags do not always win that argument.
Each route also declares its own canonical explicitly rather than relying on the URL that happened to be requested:
alternates: { canonical: `/for/${article.slug}` },
One function knows where an article lives
Because articles cross-link between the two spaces, something has to answer "what is the URL of this slug". Exactly one thing does:
/** 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}`;
}
Every related link, and every entry in the sitemap, goes through that. An article's relatedArticles is a flat list of slugs with no prefixes in it, so a guide can point at an audience page without knowing anything about routing:
export function relatedArticles(article: Article): Article[] {
return article.relatedArticles
.map(getArticle)
.filter((other): other is Article => other !== undefined && other.slug !== article.slug);
}
Two things in there are load bearing. getArticle searching ALL_ARTICLES is what makes cross-space links possible at all. And the self-filter exists because a copy-pasted list of related slugs will eventually include its own page, which renders as a link to the page you are on, which is the sort of detail readers notice immediately and never mention.
The shared layout takes the href as an argument instead of calling articleHref itself:
/**
* 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 }) {
It is the same string either way today. It is passed in so that a future route serving these documents somewhere else cannot end up emitting structured data pointing at a URL it did not serve, which would be invisible in the browser and wrong in every consumer that reads the markup.
Where the links actually go
relatedSites is a list of /alerts/<slug> pages, resolved against the site catalogue at render time:
const sites = article.relatedSites
.map(getAlertSite)
.filter((site): site is NonNullable<ReturnType<typeof getAlertSite>> => site !== undefined);
So a guide about being first to a listing links to the specific portals it talks about, and the per-site pages link back into the guides. Articles are not a separate island with a hub page and nothing else, which is the failure I wrote about in a sitemap is not internal linking. The undefined filter matters because these are strings: a renamed site slug would otherwise crash the render of an unrelated article, and dropping a dead link is the correct behaviour for a page that is otherwise fine.
The rule the sections exist to enforce
/**
* 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 section type is built for prose first:
export type ArticleSection = {
heading: string;
body: string[];
/** Optional numbered or bulleted points under the prose. */
points?: { title: string; body: string }[];
/** Optional monospaced block, a template, a checklist, a URL. */
callout?: { label: string; text: string };
};
body is an array of paragraphs and it is not optional. points and callout are. A section cannot be a bare list of bullets with a heading, which is what these pages turn into when nobody is enforcing anything, and sources is a required field on every article for the same reason the per-site pages carry citations.
readingMinutes carries a comment telling you to keep it honest, which is the kind of comment that only gets written after somebody has been tempted.
Both routes are force-static with generateStaticParams, so all of this is HTML at build time, and the title and description of every entry are checked against the search engine truncation budget by a test rather than by eye: fifty nine characters.
The bit I have not tidied
There is no /for index page. Audience pages borrow the guides hub as their breadcrumb parent, and the ternary that does it has two identical branches, which is a leftover from when I thought they would differ:
article.kind === "audience"
? { label: "Guides", href: "/guides" }
: { label: "Guides", href: "/guides" },
It behaves correctly and it reads like a bug, which means it is one: the next person to touch it has to work out whether the duplication is meaningful. Either the audience pages get their own hub, or that collapses to one line. Writing the post is what made me look at it, which is a reason to write posts.
See it for yourself
The two spaces are notifio.app/guides for the task pages and pages like notifio.app/for/students and notifio.app/for/expats for the audience ones. Follow the related links at the bottom of how fast do rental listings go and you will land in the per-site pages such as Pararius, which is the cross-space linking described above doing its job. The app those pages are about is at notifio.app/download.
Top comments (0)