DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our header ships three links and a dropdown that is not in the HTML, so the footer is the whole site map

Run this against our homepage:

curl -s https://pub-trivia.app/ \
  | python3 -c "import sys,re;h=re.search(r'<header.*?</header>',sys.stdin.read(),re.S).group(0);print(re.findall(r'href=.([^\"]+).',h))"
Enter fullscreen mode Exit fullscreen mode

You get six links. The logo, three cluster links, and the two auth buttons. The site has around seventy public pages organised into six content clusters, and four of those six hubs do not appear in that header at all.

They are in a dropdown. The dropdown is a Radix menu, so its items are mounted when it opens and do not exist in the server rendered HTML. And the nav that holds the three links that are there carries hidden md:flex, so on a narrow viewport none of them are visible either.

Google indexes the mobile page. Put those two facts together and you get the one that reorganised our footer: on the version of the site that is actually crawled, the footer is the only site-wide navigation there is.

What that means for what belongs in a footer

The footer used to be four columns typed out inside app/page.tsx. Fine when the site had eight pages. Not fine now, and the reason is not "duplication is bad", it is that the footer had become load bearing without anyone deciding it should be.

So it is generated from the content registry, the same single array every page's metadata, breadcrumbs and sitemap entry come from, and the rule it follows is: every cluster hub has to be in it.

export function footerColumns(): { heading: string; links: NavLink[] }[] {
Enter fullscreen mode Exit fullscreen mode

There was already a smaller version of this lesson in the codebase. Four company links, About plus the three legal pages, were typed out in three separate files, and they had already fallen out of step: /about was added to the homepage footer only. The page was reachable from exactly one page on the site, and from nowhere at all if you landed on /legal/terms. That is now one exported array, for the same reason.

Derive what will drift, decide what is editorial

The thing I want to push back on is the instinct to generate the whole navigation. Two of the three decisions in this file are not derivable.

Which clusters get their own link in the header bar, and which get folded into Resources, is an editorial judgement:

const HEADER_CLUSTERS: readonly ClusterKey[] = ['features', 'solutions']
const RESOURCE_CLUSTERS: readonly ClusterKey[] = ['guides', 'quiz-questions', 'tools', 'compare']
Enter fullscreen mode Exit fullscreen mode

Features, Solutions and Pricing are what a buyer is looking for. The rest is what a reader browses. No property of a cluster encodes that, so it is written out as an explicit ordered list, which has a side benefit: adding a cluster in a later phase cannot silently push Pricing off the end of the bar.

What is derived is publication. A cluster appears in the nav because its hub page exists, not because a flag somewhere says it should. And the Resources menu returns an empty array until at least one of its clusters is built, so the header does not carry an empty dropdown through the phases where none of them are written yet.

A column with one link in it reads as a bug

if (product.length > 1) columns.push({ heading: 'Product', links: product })
Enter fullscreen mode Exit fullscreen mode

A "Resources" heading above a single "Guides" link looks like something failed to load. During the phases where only some clusters existed, that is exactly what a naive split produced.

But dropping the column has a worse consequence than looking odd, and this is the part I did not see at first: if a cluster's only footer link lives in a column that gets suppressed, that cluster now has no footer link at all. On mobile, where the footer is the only navigation, it has no site-wide link anywhere. The fix is that when exactly one cluster is left over, it gets promoted to a column of its own spoke pages instead of being dropped:

if (rest.length > 1) {
    columns.push({ heading: 'Resources', links: rest.map(toLink) })
} else if (rest.length === 1) {
    columns.push(spokeColumn(rest[0]))
}
Enter fullscreen mode Exit fullscreen mode

One cluster lists its pages by name, and which one is a product question

const FOOTER_SPOKE_CLUSTER: ClusterKey = 'solutions'
const FOOTER_SPOKE_LIMIT = 5
Enter fullscreen mode Exit fullscreen mode

Solutions, because "is there a page for my kind of venue" is the question that actually sends somebody looking, and a column reading Pubs, Bars, Restaurants, Breweries and taprooms, Sports bars answers it where a single "Solutions" link does not.

Features are better served by their hub. Nobody arrives wanting "team scoring" without first wanting quiz software, so a column of feature names is answering a question in the wrong order.

Five spokes, then a link on to the rest:

if (spokes.length > links.length) {
    links.push({ label: `All ${cluster.label.toLowerCase()}`, href: cluster.path })
}
Enter fullscreen mode Exit fullscreen mode

And a cluster with no spokes yet still gets its hub in the column, so the column can never render empty.

Two things Tailwind and React quietly got wrong here

The grid column count depends on how many columns were generated, which is the obvious case for a template literal and the one place you must not use one:

const COLUMN_CLASS: Record<number, string> = {
    4: 'lg:grid-cols-4',
    5: 'lg:grid-cols-5',
    6: 'lg:grid-cols-6',
}
Enter fullscreen mode Exit fullscreen mode

Tailwind scans your source as text. lg:grid-cols-${n} is a class name it has never seen, so it never generates the rule, and you get a footer that silently falls back to one column in production while looking right in dev. Literal strings in a lookup table, with a sane default, is the whole fix.

The other one is the copyright year:

export function CopyrightLine() {
    return <p>© {new Date().getFullYear()} PubTrivia, a Sonacode Ltd company. ...</p>
}
Enter fullscreen mode Exit fullscreen mode

new Date() in a server component is evaluated when the page renders. On a statically prerendered page, that is the build year, not the current one. The pages using this footer are static, so the year is passed in by the caller only where it can be kept live. Otherwise the build year stands, because a stale year in a footer is a much smaller problem than opting an entire page into dynamic rendering to keep it fresh.

See the difference yourself

Open pub-trivia.app on a desktop window and note the three links in the middle of the header plus the Resources dropdown. Then narrow the window under 768px, or switch on your browser's device toolbar, and watch all of it disappear. Scroll to the bottom and the footer is unchanged: Product, Solutions by venue, Resources, Company, Account.

To see what a crawler sees, count the ways to reach one of the hubs that is not in the header:

curl -s https://pub-trivia.app/ | grep -o 'href="/guides"' | wc -l
Enter fullscreen mode Exit fullscreen mode

One. /guides appears exactly once in the entire document, in the footer, and that is the only route by which a crawler on any page of the site can reach the guides cluster at all. Run it against any other page and you get the same answer, because the footer is the same everywhere and the header never links there.

Then open any deep page, say pub-trivia.app/solutions/breweries-and-taprooms, and check the footer is identical there. That is the actual requirement. Not that the footer looks nice, but that landing on one page out of seventy from a search result puts the other sixty-nine within two clicks.

Top comments (0)