DEV Community

Daniel Pertu
Daniel Pertu

Posted on

69 pages are one TypeScript array, and nothing else on the site is allowed to hold a list

Before any of the content existed, our site had eight public pages and three lists of routes: the auth middleware's allowlist, the sitemap, and robots.txt. They were written out independently, and they disagreed. /about shipped in the sitemap while the auth gate bounced every crawler that followed it to /login.

Three lists, eight pages, one silent bug. The content plan then added sixty more pages, each needing a title, a meta description, a canonical, a sitemap entry, a breadcrumb trail, a position in the header, a column in the footer, a slot in its hub's index, and links to its neighbours.

That is eight more hand-kept lists, and the same disagreement at eight times the scale. So we stopped keeping lists.

One node per page

export type ContentNode = {
    /** Root-relative, with a leading slash. The homepage is '/'. */
    path: string
    /** The SHORT title. The root layout's template appends the site name. */
    title: string
    /** The page's <h1>, which is allowed to be longer than the title. */
    heading: string
    /** What to call this page in a breadcrumb or a footer column. */
    shortLabel?: string
    /** The meta description, and the line shown wherever another page links here. */
    description: string
    cluster?: ClusterKey
    /** YYYY-MM-DD. Published as this page's lastmod. */
    updated: string
    related: readonly string[]
}
Enter fullscreen mode Exit fullscreen mode

Sixty-nine of those, in one array, in registry order. Below them, one comment that is also a rule:

// ─────────────────────────────────────────────────────────────────────────────
// Derivations. Everything below is computed; nothing below is a second list.
// ─────────────────────────────────────────────────────────────────────────────
Enter fullscreen mode Exit fullscreen mode

What gets derived: the <title> and meta description, the canonical, the sitemap entry and its lastmod, the header links, the footer columns, the breadcrumb trail, the BreadcrumbList JSON-LD, the hub's index of its spokes, the ItemList JSON-LD on each hub, the related-links block, the Article dates, the eyebrow on the OG card, the public route prefixes the auth gate reads, and the link graph the tests run against. Adding a page is one node and one page.tsx. Everything in that list follows.

The as const satisfies is doing real work

const CONTENT_LITERAL = [
    {
        path: '/compare/paper-vs-app-quiz',
        title: 'Paper vs App Quiz Nights',
        shortLabel: 'Paper vs app',
        heading: 'Pen and paper against phones',
        description:
            'Pen and paper is the real competitor. An honest comparison: what paper is genuinely better at, what the marking costs you every week, and when to stay on it.',
        cluster: 'compare',
        updated: '2026-09-14',
        related: ['/compare/best-pub-quiz-apps', '/guides/quiz-night-equipment', '/tools/scoresheet-generator'],
    },
    // sixty-eight more
] as const satisfies readonly ContentNode[]

export const CONTENT: readonly ContentNode[] = CONTENT_LITERAL
Enter fullscreen mode Exit fullscreen mode

satisfies makes a missing description or a misspelled cluster key a compile error. as const narrows each entry to its own exact shape, which is what lets a path be used as a literal type elsewhere.

The widening on the last line is the part that is easy to get wrong. as const means a node with no cluster field has no cluster property at all, so node.cluster is not an optional read, it is a type error on that member of the union. Every derivation therefore works off the widened CONTENT. The literal exists only to be checked.

Publication is derived from existence, not declared

All six clusters are declared as soon as the plan commits to them, which is usually a phase or two before anything in them is written. A spoke needs to be able to name the hub it belongs to even while that hub is a stub. But a half-built cluster must not appear in the navigation as a dead link.

export function publishedClusters(): Cluster[] {
    return CLUSTERS.filter((cluster) => BY_PATH.has(cluster.path))
}
Enter fullscreen mode Exit fullscreen mode

Visibility is the existence of the hub's node. Finishing the hub publishes the cluster in the header, the footer and the sitemap at once, and there is no second switch to remember to flip.

The header uses that, with one honest exception:

/** Clusters shown as their own link in the header, in this order. */
const HEADER_CLUSTERS: readonly ClusterKey[] = ['features', 'solutions']

/** Clusters folded into the header's Resources menu, in this order. */
const RESOURCE_CLUSTERS: readonly ClusterKey[] = ['guides', 'quiz-questions', 'tools', 'compare']
Enter fullscreen mode Exit fullscreen mode

Which clusters sit in the bar and which fold into a menu is an editorial judgement about what a buyer is looking for versus what a reader browses. It is not derivable, so it is written down as an explicit order rather than pretended into a rule. Writing it as an order also means adding a cluster in a later phase cannot silently push Pricing off the end of the bar.

The sitemap stopped lying about dates

export default function sitemap(): MetadataRoute.Sitemap {
  const buildDate = new Date();

  return INDEXABLE_ROUTES.map((path) => {
    const updated = nodeFor(path)?.updated;
    return {
      url: `${SITE_URL}${path === "/" ? "/" : path}`,
      lastModified: updated ? new Date(`${updated}T00:00:00Z`) : buildDate,
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

Two things left this file on the way. changeFrequency and priority are gone, because Google documents both as ignored and they were eight invented numbers nobody could defend.

And lastModified used to be a single new Date() for the whole document. That was an improvement on the eight independent dates before it, and still a claim that every page on the site was revised at build time. Which is exactly the signal lastmod is supposed to carry, and exactly why a crawler learns to ignore it.

You can see the result:

curl -s https://pub-trivia.app/sitemap.xml | grep -o '<lastmod>[^<]*' | sort | uniq -c
Enter fullscreen mode Exit fullscreen mode

Sixty-nine URLs carry the date their content actually changed. Three carry the build date, and those three are the legal documents, which have no registry node and fall back deliberately.

The tests check the graph, not the code

The registry turned a pile of untestable editorial judgement into a data structure, which means the content plan's rules become assertions. Twenty-one of them, including:

  • no duplicate paths
  • no two pages sharing a title, so two of our own pages cannot compete for the same result
  • every description inside the length a search result will show
  • every rendered title inside the same, counting the site-name suffix the layout appends
  • every updated a real YYYY-MM-DD, since the sitemap publishes it
  • every internal link points at a page that exists
  • no page links to itself
  • no page is orphaned, counting header and footer links as inbound
  • every spoke links up to its hub, and every hub links down to all of its spokes
  • every spoke's path sits underneath its own hub's path
  • no cluster is published before its hub page is built
  • every content page is in the sitemap, and reachable without a session

That last one is the original bug, now a test. The link assertions run against a derived graph:

export function outboundLinksFor(path: string): string[] {
    const node = nodeFor(path)
    if (!node) return []

    const links = new Set<string>()
    for (const crumb of breadcrumbFor(path)) {
        if (crumb.path !== path) links.add(crumb.path)
    }
    if (node.cluster && isHub(node)) {
        for (const spoke of spokesOf(node.cluster)) links.add(spoke.path)
    }
    for (const related of node.related) links.add(related)

    links.delete(path)
    return [...links]
}
Enter fullscreen mode Exit fullscreen mode

This is honest about its own limit. A page that renders links the registry does not know about will still pass, and nothing can stop that. But a page that renders fewer links than this is the failure worth catching, and it is caught because the components take their links from this function rather than hard-coding them.

The one rule that keeps it true

The registry describes pages that EXIST. A node here with no page.tsx behind it is a 404 in the sitemap and a dead link in the footer, so nodes land in the same commit as their page.

Derivation is leverage in both directions. One node adds a page to fourteen surfaces, and one node for a page that does not exist breaks fourteen surfaces at once.

Have a look

  • pub-trivia.app/sitemap.xml is the array, serialised. 72 URLs, 69 of them nodes.
  • pub-trivia.app/guides is a hub. Its index, its breadcrumbs and its ItemList markup are three views of the same filtered slice.
  • Pick any guide and look at the footer, the breadcrumb and the related links at the bottom. None of those three lists is written anywhere.

Top comments (0)