SEO titles and descriptions in Next.js generateMetadata: measure pixels, keep a second title, skip the quotes
SEO title and description length in Next.js generateMetadata: measure pixels, not characters, keep a short metaTitle per post, and avoid entities.
The search result is the one ad you do not pay for, and the search engine cuts it by width, not by character count. Every headline on this blog is written for the page, things like "Next.js not-found pages: keep the real 404, and the loading.tsx that turns it into a soft 404". Turned into a <title> with the site name on the end, that is far past what a result line shows. I measured the 34 posts before this one: with the suffix, the titles run 64 to 117 characters and 574 to 1,061 pixels wide, median 830. None fits the audit's 580px budget. So every post carries a second, shorter title, and this is how that title, its description and their lengths flow through generateMetadata in the kit, with the three things I measured to get there.
One field for the page, one for the result
The post type documents the rule where the data lives:
export type BlogPost = {
slug: string;
title: string;
/**
* Compact SERP title (45–60 chars), used verbatim as the <title> — no
* "| Shipwright" suffix. Falls back to `title | Shipwright` when omitted;
* set it whenever the headline would blow past ~60 chars.
*/
metaTitle?: string;
description: string;
// ...
};
generateMetadata in app/blog/[slug]/page.tsx reads it, and keeps the full headline for the share cards:
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
const url = `/blog/${post.slug}`;
return {
title: post.metaTitle ?? `${post.title} | Shipwright`,
description: post.description,
alternates: { canonical: url },
openGraph: { type: "article", url, title: post.title, /* ... */ },
twitter: { card: "summary_large_image", title: post.title, /* ... */ },
};
}
The suffix is appended by hand on purpose. A title.template such as '%s | Shipwright' in the root layout looks tidier, but the Next.js docs are explicit that a page's title string "will augment title.template from the closest parent segment". Every metaTitle would get the suffix and lose 110px of its budget (that is what " | Shipwright" measures at 20px Arial), unless you wrote each one as { absolute: post.metaTitle }, the escape hatch that "ignores title.template set in parent segments". With one place that builds titles, a plain string is simpler.
Measure pixels, not characters
Character limits are a proxy, and a loose one. In Chromium, at 20px Arial (here Liberation Sans, which has Arial's metrics), 45 capital Ws measure 849px and 60 lowercase letter i's measure 267px. The kit's audit keeps both checks: 45–60 characters and roughly 580px for titles, 140–160 characters and roughly 990px for descriptions at 14px. The pixel number comes from a small width table:
/** Rough SERP pixel width (Arial-ish table at 18px, scaled). */
function estPx(text, fontPx) {
let w = 0;
for (const ch of text) {
if (/[iljtfI.,:;'!|()[\] ]/.test(ch)) w += 5.5;
else if (/[MW@—]/.test(ch)) w += 17;
else if (/[A-Zmw]/.test(ch)) w += 13;
else w += 9.5;
}
return Math.round((w * fontPx) / 18);
}
To see how rough "rough" is, I measured every metaTitle and description on the blog with canvas measureText and compared. The table overestimates titles by 8 to 44px (23 on average) and descriptions by 17 to 56px (35 on average). It always errs toward a warning, which is the direction you want from a gate. The real widths came out at 408–526px for titles and 882–960px for descriptions, all inside budget. One case shows why both checks exist: the shortest fallback title is 64 characters, over the character limit, but measures 574px, under the pixel one.
Treat 580 and 990 as working budgets, not a spec. The search engine's font and cutoff are not published as a contract, and they change. If you want a real number for one string, the browser gives it to you:
const ctx = document.createElement("canvas").getContext("2d");
ctx.font = "20px Arial";
ctx.measureText("Next.js Not Found Pages: Real 404s, Not Soft 404s").width; // 456 here
Keep the description free of entities
React escapes attribute values when it renders the <meta> tags, and it is not subtle about it. Rendering one description through react-dom/server 19.2:
<meta name="description" content="Don't ship a "free" tier on Opus & hope: cap it <before> launch."/>
That is 64 characters on screen and 89 in the HTML source. Browsers and search results show the decoded text, but any tool that counts the raw attribute sees 89 and flags a description that is fine. Worse, you start trimming good text to satisfy it. The kit's audit decodes before counting, then warns separately whenever the raw and decoded lengths differ. The house rule that follows is blunt: no & < > " ' in titles or descriptions. Write "and", drop the quotation marks, avoid contractions. Every title and description on this blog is written that way.
Things that bite
- Unknown slugs return
{}. The not-found page then renders under the root layout's title, the homepage's. It is harmless because the page isnoindex, as covered in Next.js not-found pages and soft 404s. - A page's
openGraphreplaces the layout's entirely, so the share card needs its own title, description and url. The details are in Open Graph images in the Next.js App Router. - Duplicates are site-wide. Two posts with the same
metaTitleor description each pass on their own. The audit compares every page and warns on repeats, and errors on a repeated canonical. - Check before you commit.
npm run seo:audit -- http://localhost:PORTagainst a local production build catches a long title before a crawler does. Character counts in your editor will not.
Shipwright, the Next.js and Claude starter kit this blog documents, ships this metadata setup with the metaTitle field, the per-page canonical and the audit script that measures both lengths.