DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Fifteen required fields and no optional ones, because the type decides whether a site gets a page

Notifio has a page per rental site it can watch. There are fifteen of them right now, grouped by market on the hub page: Kamernet, Pararius, Funda, Rightmove, SpareRoom, Idealista, WG-Gesucht and eight more.

Fifteen pages generated from one TypeScript array, each targeting a search like "kamernet alerts", is a pattern with a name. The name is "doorway pages", and Google treats a set of pages that differ only by a noun as a single low-value page. So the interesting engineering here is not the generation. It is the thing that stops the generation from producing that.

What stops it is the type.

The type is the policy

/**
 * The shape of an /alerts/[site] page.
 *
 * Every field here is prose written for one specific site. The page template in
 * app/alerts/[site]/page.tsx contributes layout and nothing else: there is no
 * sentence in it with the site name interpolated into it, because a set of pages
 * that differ only by a noun is a doorway-page pattern and gets treated as one.
 * If a field below cannot be filled with something true about this site and this
 * site alone, the site does not get a page.
 */
export type AlertSite = {
Enter fullscreen mode Exit fullscreen mode

Fifteen top-level fields. Zero of them optional. I checked that number while writing this, expecting to find a couple of ?: marks that had crept in, and there are none, which is the point: an optional field is an invitation to ship a page with a hole in it. A required field means the compiler refuses to build the site until somebody has written the sentence.

The fields that actually carry the weight:

  /**
   * What this site's own notifications actually do, and where that leaves you.
   * The heart of the page: it is the part that is different everywhere, and the
   * part a reader searching "<site> alerts" actually came for.
   */
  nativeAlerts: {
    heading: string;
    body: string[];
    sources: Source[];
  };
Enter fullscreen mode Exit fullscreen mode
  /** Site-specific behaviour a buyer needs to know before paying us. */
  notes: {
    /** Does the site need a logged-in session to show results? */
    login: string;
    /** Anything about how the site renders or paginates that matters. */
    rendering: string;
    /** Honest status of auto-reply on this site. */
    autoReply: string;
  };
Enter fullscreen mode Exit fullscreen mode

Those three notes render as a definition list with fixed terms and per-site answers. The third one is the commercially uncomfortable one. Auto-reply is a paid add-on, and the honest answer for some sites is that it does not work there, which means the page for that site says so above the buy button. That is the field most likely to be quietly turned into a tick in a feature matrix, which is exactly why it is typed as prose and not as a boolean.

Every claim about somebody else carries a URL

/**
 * A claim about a third party's product, with the page it came from.
 *
 * Anything we say about how another company's alerts behave carries one of
 * these. The citation is rendered on the page, which keeps us honest and gives
 * a reader a way to check a detail that may have changed since we wrote it.
 */
export type Source = {
  label: string;
  url: string;
};
Enter fullscreen mode Exit fullscreen mode

Twenty five of these across the fifteen pages. They render under the relevant section with a label that admits the obvious problem:

Sources, so you can check whether {site.name} has changed this since we wrote it:{" "}
Enter fullscreen mode Exit fullscreen mode
<a href={source.url} rel="nofollow noopener" target="_blank">
Enter fullscreen mode Exit fullscreen mode

nofollow because these are citations, not endorsements, and several of them point at a competitor's pricing page.

The reason the citation is a type and not a convention is drift. Every one of these pages describes a product somebody else controls and can change next Tuesday without telling us. A claim with a URL attached can be rechecked by anybody, including a reader who thinks we are wrong. A claim without one is a thing we will eventually be unable to defend and unable to find.

It also constrains how the competitive argument gets made. The structural case is fine to state without a citation: a portal's alert travels through an indexer or matcher job, a notification job, an email service provider, a send queue, the recipient's mail server and finally the mail client, every hop of which is scheduled work running at a volume of hundreds of thousands of messages a day. "Immediate" describes the moment the portal decided to notify you. A specific latency figure would need a first-party source, and inventing one is the single thing that would discredit a page carrying visible citations.

Where the template does interpolate the name

The type's comment says there is no sentence in the template with the site name in it. I went and counted while writing this, and the template interpolates site.name seven times across six lines. So let me be precise about what the rule actually is in practice:

  • four section headings, including {site.name} alerts: common questions
  • one call-to-action heading, Start watching {site.name} today
  • one label above the citation list
  • one sentence, in the "related sites" block, which reads "Notifio runs every monitor at once, so {site.name} does not have to be the only search you are watching"

That last one is a sentence with the name in it. It is also a claim about our product rather than about theirs, and it is identical on all fifteen pages because the fact it states is identical on all fifteen pages. The rule that is actually being enforced is narrower and more useful than the comment: every sentence that makes a claim about the site comes from the data file. Structural furniture can take the noun.

One of the seven is my favourite piece of code in the repo:

/**
 * "an Idealista monitor", not "a Idealista monitor".
 *
 * Letter-based rather than phonetic, which is right for every name in the
 * catalogue: Idealista, ImmobilienScout24 and OpenRent take "an", and none of
 * the consonant-initial names is one of the exceptions where the sound and the
 * spelling disagree.
 */
function indefiniteArticle(name: string) {
  return /^[aeiou]/i.test(name) ? "an" : "a";
}
Enter fullscreen mode Exit fullscreen mode

A regex on the first letter is the wrong general solution to English indefinite articles and the right solution for a closed set of fifteen proper nouns you can read. The comment states the audit that makes it correct, which is the part that would otherwise be lost.

One array, five consumers, and one deliberate copy

/**
 * Every site that has an /alerts/<slug> page.
 *
 * This array is the single source for the hub grid, the route's static params,
 * the footer column and the sitemap. Adding a site is one edit here; forgetting
 * to list it somewhere is not a failure mode that exists.
 */
export const ALERT_SITES: readonly AlertSite[] = [
  ...NETHERLANDS_SITES,
  ...UK_SITES,
  ...EUROPE_SITES,
];
Enter fullscreen mode Exit fullscreen mode

The real consumer list is five: sitemap.ts, the homepage grid, generateStaticParams, the hub page's market sections, and the hub page's ItemList structured data.

The footer is the exception, and it is on purpose:

/**
 * A hand-picked five, rather than the full catalogue from `lib/alerts`.
 *
 * The footer is a client component, and importing the catalogue here would ship
 * every word of every site page to the browser on every route to render five
 * links.
 */
const FOOTER_SITES = [
Enter fullscreen mode Exit fullscreen mode

That is a duplication I would normally refuse. The catalogue is three source files of several hundred lines of prose each, and importing it into a client component pulls all of it into the client bundle on every route to render five anchors. Five hardcoded slugs in the footer is the cheaper mistake, and the comment is there so the next person does not helpfully fix it.

Two small functions that keep half-finished work invisible

/** Resolves `related` slugs, dropping any that do not exist yet. */
export function relatedSites(site: AlertSite): AlertSite[] {
  return site.related
    .map(getAlertSite)
    .filter((related): related is AlertSite => related !== undefined && related.slug !== site.slug);
}
Enter fullscreen mode Exit fullscreen mode

The related field is three sibling slugs for internal linking. Dropping unresolved ones means you can write the links you intend to have before the pages exist, and a half-written catalogue renders two cards instead of three rather than linking to a 404. The self-exclusion check is there because a copy-pasted site entry will reference itself and nobody notices in review.

The same instinct on the hub page:

/** Markets that currently have at least one page, in MARKETS order. */
export function populatedMarkets() {
  return MARKETS.map((market) => ({ ...market, sites: sitesInMarket(market.key) })).filter(
    (market) => market.sites.length > 0,
  );
}
Enter fullscreen mode Exit fullscreen mode

Six markets are declared with their own section blurbs, and six are currently populated: five Dutch sites, four UK, two German, two pan-European, one Spanish, one marketplace. The filter does nothing today. It exists because the next market will be declared with a blurb before its pages are written, and the alternative is an empty heading shipped to production.

Everything is force-static, since the content is a TypeScript literal:

/**
 * Statically generated: the content is a TypeScript literal and changes when we
 * deploy, so there is nothing to render per request.
 */
export const dynamic = "force-static";
Enter fullscreen mode Exit fullscreen mode

Schema as one graph, not three tags

/**
 * One @graph rather than three separate script tags, so the FAQ, the trail and
 * the application resolve as one connected description of this page.
 */
Enter fullscreen mode Exit fullscreen mode

The FAQ nodes come straight from the same faqs array that renders the visible accordion, which is the only rule worth enforcing about FAQ markup: there are sixty FAQs across the fifteen pages and there is no code path by which a question can exist in the markup and not on the page.

Related reading

This is the same argument I made about the competitor comparison pages in Our comparison tables have three states, and 15 of their 46 rows hand the win to the other product, from the other end: that post was about the data being allowed to say we lose, this one is about the type refusing to let a page exist until somebody has written something true. And the tests that keep the content honest rather than the code correct are in Our most valuable tests do not test code, they assert that our content is true.

Go and read two of the pages side by side, say Rightmove and Idealista, and the test is simple: if you could swap the body text between them and not notice, the type has failed.

Top comments (0)