Nakodo's sitemap has 76 URLs:
$ curl -s https://nakodo.app/sitemap.xml | grep -c '<loc>'
76
Those pages are built by 14 calls to one function, because one of the 14 is the renderer for the 63 pages that come out of a content registry. The function is 20 lines, in src/lib/seo/metadata.ts, and its comment is the entire design:
// Every public page's metadata comes from here, so canonical, Open Graph and
// Twitter tags can't drift between pages. Relative URLs resolve against
// metadataBase in the root layout.
export function pageMetadata(p: PageMeta): Metadata {
Metadata drift is the most boring bug in SEO and the easiest one to ship. A page gets a canonical and no og:url. A page gets an og:title from its H1 and a <title> from an older draft. A new page type gets copied from a sibling that was missing the image dimensions. Nobody notices for three months, because nothing is broken: it is just slightly wrong on 11 of 76 pages.
The type is where the rules live
export type PageMeta = {
/** The <title> before " · Nakodo" is appended. Written in the searcher's words, at most 50 characters. */
title: string;
/** At most 158 characters. Says what the page answers, not what the company is. */
description: string;
/** Canonical path, e.g. "/guides/how-to-find-youtube-influencers". */
path: string;
/** Shorter title for social cards, which truncate harder. Falls back to `title`. */
ogTitle?: string;
keywords?: string[];
type?: "website" | "article";
/** ISO dates, for articles. */
published?: string;
modified?: string;
/** Extra <link rel="alternate"> types, e.g. the blog's RSS feed. */
feeds?: Record<string, string>;
};
Three required fields, and the two character budgets are in the doc comments where an editor sees them on hover. The numbers themselves are constants with the arithmetic written out:
// The layout appends " · Nakodo" (9 characters) to page titles. Titles stay at
// or under this so the rendered title is under 60 characters, and descriptions
// under 160, which is what Google's snippet shows.
export const MAX_TITLE = 50;
export const MAX_DESCRIPTION = 158;
The suffix is a Next.js title template in the root layout, which also means the home page is the one page that is not suffixed:
title: { default: HOME_TITLE, template: `%s · ${BRAND.name}` },
$ curl -s https://nakodo.app/ | grep -oE '<title>[^<]*</title>'
<title>Nakodo: find influencers who fit your brand</title>
$ curl -s https://nakodo.app/for/small-businesses | grep -oE '<title>[^<]*</title>'
<title>YouTube influencer marketing for small business · Nakodo</title>
default rather than absolute on the home page, because default is what a child route inherits when it sets no title of its own, and there is no such route here, so the two behave the same until somebody adds one.
Relative in, absolute out
return {
title: p.title,
description: p.description,
...(p.keywords?.length && { keywords: p.keywords }),
alternates: { canonical: p.path, ...(p.feeds && { types: p.feeds }) },
openGraph: {
type: p.type ?? "website",
url: p.path,
siteName: BRAND.name,
locale: "en_US",
title: ogTitle,
description: p.description,
images: [{ url: cardUrl(p.path), width: 1200, height: 630, alt: ogTitle }],
...(p.type === "article" && { publishedTime: p.published, modifiedTime: p.modified, authors: [BRAND.name] }),
},
twitter: { card: "summary_large_image", title: ogTitle, description: p.description },
};
Every URL handed to Next is a path. The absolute form is produced once, by metadataBase:
// Relative canonical, Open Graph and image URLs resolve against production,
// whichever deployment renders them (src/lib/seo/site.ts).
metadataBase: new URL(SITE_URL),
and SITE_URL is deliberately not the current deployment:
// The production address, in every environment. Canonical URLs, the sitemap and
// structured data name the public site, so a preview deployment points them at
// production rather than at itself (Vercel sends noindex on previews anyway).
// Taken from the family list so the domain is written down once.
export const SITE_URL = SELF_APP.url;
This is the decision people argue about, so here is the reasoning in full. A preview deployment is a copy of the site on a URL that should never be indexed or shared. If its canonicals name itself, then anything that does leak (a link pasted in a chat, a crawler that ignores noindex, a social card debugger) advertises a throwaway hostname as the canonical home of the content. If its canonicals name production, the worst case is that a preview claims production's identity, which is harmless because nothing links to a preview.
The cost is real and worth saying: in a preview, the OG image URLs point at production too, so you cannot use a preview to check a card you have just changed. You check that card locally, where SITE_URL is whatever the environment says, or you look at the /og/... route directly.
The tags you get for free, and the one that has to be fixed
The twitter object sets three fields. The rendered page has seven tags:
$ curl -s https://nakodo.app/guides/how-to-find-youtube-influencers | grep -oE '<meta name="twitter:[^"]*"'
<meta name="twitter:card"
<meta name="twitter:title"
<meta name="twitter:description"
<meta name="twitter:image"
<meta name="twitter:image:alt"
<meta name="twitter:image:width"
<meta name="twitter:image:height"
Next copies the Open Graph image, its alt text and its dimensions into the Twitter block when the Twitter block has no image of its own. So the correct amount of code for Twitter cards, in an app that already has good OG tags, is the three fields that genuinely differ.
The image URL is chosen by a two-function module:
export function cardTitle(path: string): string | null {
const entry = entryAt(path);
if (entry) return entry.page.ogTitle ?? entry.page.h1;
return STATIC_CARDS.get(path) ?? null;
}
export const hasCard = (path: string) => cardTitle(path) !== null;
export function cardUrl(path: string): string {
return hasCard(path) ? `/og${path}` : "/opengraph-image";
}
So a page's card lives at its own path with /og in front, drawn on demand from the title in the registry, and anything without a card falls back to the site-wide one:
$ curl -s https://nakodo.app/guides/how-to-find-youtube-influencers | grep -oE 'og:image" content="[^"]*'
og:image" content="https://nakodo.app/og/guides/how-to-find-youtube-influencers
$ curl -s https://nakodo.app/ | grep -oE 'og:image" content="[^"]*'
og:image" content="https://nakodo.app/opengraph-image
$ curl -s -o /dev/null -w '%{http_code}\n' https://nakodo.app/og/not-a-page
404
hasCard also exports the list of paths that have one, which is what the card route's generateStaticParams iterates, and an unknown path under /og is a 404 rather than a generic image. Those two facts are the same fact: the set of cards is the content registry, not whatever somebody requests.
Article metadata is gated on being an article
...(p.type === "article" && { publishedTime: p.published, modifiedTime: p.modified, authors: [BRAND.name] }),
Three tags that only mean something on a dated piece, and a spread that contributes nothing on the other 70 pages. The feeds field does the same job for the one page that has a feed:
$ curl -s https://nakodo.app/blog | grep -oE '<link rel="alternate"[^>]*>'
<link rel="alternate" type="application/rss+xml" href="https://nakodo.app/blog/feed.xml"/>
One page passes feeds, so one page gets the link, and the type of feeds is Record<string, string> because the field it feeds, alternates.types, is keyed by MIME type.
What I found while checking my own claims
Two things, and I prefer posts that say so.
The comment above cardUrl explains the fixed URL by saying that a segment's opengraph-image file gets a hash appended by Next, which structured data then cannot name. On this deployment, the home page's tag is the plain path:
https://nakodo.app/opengraph-image
No hash, because the home card is a generated route rather than a static file in this build. The conclusion still holds for the reason the next sentence of the comment gives (the JSON-LD graph names the same image URL, so the URL has to be one we can write down), but the stated mechanism is not visible here, and a comment that explains a decision with an effect you cannot observe is a comment that will be deleted by someone who checks.
The character budgets are enforced by a test, and the test iterates the content registry:
assert.ok(page.metaTitle.length <= MAX_TITLE, `title is ${page.metaTitle.length} chars`);
The 13 hand-built pages are not in that registry, so nothing checks them. I measured them from outside:
/pricing 56 Pricing for YouTube creator search and outreach · Nakodo
/how-it-works 58 How it works: the brief, the search and the score · Nakodo
/guides 50 Guides to finding and sponsoring creators · Nakodo
/alternatives 55 Alternatives to influencer marketing platforms · Nakodo
/blog 48 Blog: creator outreach and sponsorships · Nakodo
/glossary 46 Glossary of YouTube sponsorship terms · Nakodo
...
Fine today, with two characters of headroom on the worst one, and entirely by hand. The fix is not another test file: it is that STATIC_PAGES already exists as a list, the same list cardTitle and the sitemap read, so the existing test can iterate it too. That is the useful shape of this whole area, and it took writing a blog post about it to notice: when every page's metadata comes from one function, the list of pages is also one list, and anything you can assert about one page you can assert about all 76.
Top comments (0)