Notifio is a desktop app that watches rental listing pages and tells you when something new appears. The marketing site around it has a page per supported rental site: notifio.app/alerts/kamernet, notifio.app/alerts/pararius, and thirteen more, all listed on notifio.app/alerts.
Those pages are not a template with a site name interpolated into it. Every sentence on each one is written for that site, and the data lives in one catalogue:
export const ALERT_SITES: readonly AlertSite[] = [ ...NETHERLANDS_SITES, ...UK_SITES, ...EUROPE_SITES, ]; The comment above it says what the array is for:
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.
Three of those four are true. The footer is the one that lies.
The footer column is hand-written
/** * 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. These are the highest-traffic guides; everything else is one click away * behind "All supported sites". */ const FOOTER_SITES = [ { slug: "kamernet", name: "Kamernet" }, { slug: "pararius", name: "Pararius" }, { slug: "funda", name: "Funda" }, { slug: "rightmove", name: "Rightmove" }, { slug: "spareroom", name: "SpareRoom" }, ]; A slug and a display name, five times over, typed out by hand next to a comment explaining why.
The numbers behind that decision are easy to check in the repo:
$ wc -c src/lib/alerts/sites/*.ts 36287 europe.ts 26712 netherlands.ts 25704 uk.ts 88703 total $ cat src/lib/alerts/sites/*.ts | gzip -9 | wc -c 26138 87KB of source, 26KB gzipped, and that is prose: the per-site paragraph about what that portal's own email alerts actually do, its setup steps, its three or four FAQ answers, its citations. The AlertSite type has fifteen required fields and most of them are sentences.
A server component importing that array costs nothing at the browser. A client component importing it pulls those three files into the client module graph, and a footer renders on every route, so every page on the site would carry the full text of all fifteen landing pages in order to print five anchor tags.
Why the footer is a client component at all
Because of a decoration. There is a giant wordmark that rises out of the bottom of the footer as you scroll into it, and it uses framer-motion:
const { scrollYProgress } = useScroll({ target: footerRef, offset: ["start end", "end end"], }); const y = useTransform(scrollYProgress, [0, 0.75], ["100%", "0%"]); useScroll with a target needs a ref to the element whose scroll progress it tracks. The element in question is the <footer>, but the component holding the hook is a child of it, so it does not own that ref. It goes and finds it:
useEffect(() => { let el: HTMLElement | null = wordmarkRef.current; while (el && el.tagName !== "FOOTER") { el = el.parentElement; } footerRef.current = el; }, []); A while loop climbing parentElement until it hits a tag name. This is the sort of thing that reads as a smell and is in fact the smallest honest option: the alternative is threading a ref down from the footer into the decoration purely so the decoration can measure the thing it is already inside, and the DOM relationship is guaranteed by the component that renders it.
The clipping is the other half. The wordmark starts fully below the bottom edge and overflow-hidden on the parent hides it. Two clamp() values keep that exact:
style={{ height: "clamp(5rem, 16vw, 16rem)" }} ... style={{ y, fontSize: "clamp(5rem, 16vw, 16rem)" }} Identical expressions, deliberately. The container is exactly one line of the wordmark tall, so y: "100%" puts the text precisely out of frame and nothing has to be nudged by hand at any viewport width. It also carries aria-hidden="true", because a decorative repeat of the product name is not information for anyone using a screen reader.
What the duplication actually costs
Two things, and they are worth naming rather than pretending the comment resolves them.
A site removed from the catalogue would still have a footer link pointing at a 404, because nothing connects FOOTER_SITES[n].slug to ALERT_SITES. In practice slugs are URLs we have published and cannot change, so the risk is one direction only: a renamed slug breaks the footer, and a renamed slug breaks every inbound link too, which is a bigger alarm than the footer.
And the names can drift. The catalogue stores name "exactly as the site spells it", and the footer stores its own copy. Five strings. If one of those portals rebrands, the footer is a second place to fix.
Neither of those is free. Both are cheaper than 26KB on every route, which is why the footer keeps its own list and the comment above it explains the trade rather than hiding it. The honest version of the single-source comment in lib/alerts/index.ts would say three places, not four.
The links that are generated
Everything else in the footer is a static route, and the full catalogue is one click behind "All supported sites":
<li> <Link href="/alerts">All supported sites</Link> </li> Which is the actual resolution. The page that needs all fifteen is the hub, it is a server component, and it imports the catalogue directly and pays nothing for it. The comparison pages under notifio.app/compare work the same way.
If you want to see the wordmark do its thing, scroll to the bottom of notifio.app/guides. It is the only part of this post you cannot verify from the snippets.