seo · aeo · nextjs · metadata

12 of my 15 meta descriptions were too long for search

What a metadata audit found, the fix that shipped, and the robots.txt change the audit recommended and got wrong

ReportedSEP 26·7 min read·by Abdur Rahman Sayeed

Short answer: Google doesn't set a character limit on meta descriptions. It truncates the snippet to fit the device width. So pick a budget, around 155 characters for descriptions and around 60 for titles, write each description as one complete thought inside it, and measure the rendered HTML rather than your source files. My site was over budget on 12 of 15 pages before I did that.

What the audit found

On September 10 a Claude Code session audited abdur.ai's metadata, and I approved its fixes. The numbers are in the fix commit, c57a046:

The descriptions weren't wrong. They were long: written to be read in full, not to survive a cut. So wherever Google used one of those descriptions, it was likely to be cut before the sentence finished. I never checked a live results page: Search Console had never been set up for the domain, which the same commit started fixing.

Two smaller findings came out of the same pass. /now had a 49-character description, short rather than long, that said "Updated weekly," while the page's own date read June 27. That's a claim the page didn't back up, so the fix removed the cadence. And the audit found next.config.mjs setting a Content-Type header for /aitldr/:slug/llms.txt, a route that doesn't exist. The only real one is app/llms.txt/route.ts.

Why "about 155" and not a rule

Google's snippet documentation says there's no limit on how long a meta description can be, but the snippet "is truncated in Google Search results as needed, typically to fit the device width." Its title-link docs say the same about titles. Google also says snippets come primarily from page content and it only sometimes uses the meta description.

So 155 and 60 are budgets I chose, not limits Google publishes. A character count is a stand-in for a pixel width, and a wide letter uses more of it than a narrow one. I use a budget because it's checkable, and a description written as one complete thought inside it survives truncation better than a paragraph that gets cut off.

Who this bites

Anyone whose meta description comes from the same field as their teaser copy. It's a sensible default: one description field feeding the index card, the RSS item, the share card and the meta tag. Then someone writes a good 250-character teaser and it goes out to search too.

It also bites titles, because a post's h1 and its <title> want different things. A good h1 can run long. The SERP title has to fit, and in Next.js the layout's title template adds a site suffix ( · abdur.ai here) that counts against it.

What I changed

The fix is c57a046 (task S-1, PR #49):

  1. Rewrote every post description as one complete thought within budget. After the fix, the ten posts run from 141 to 152 characters.

  2. Kept the long copy where it was still in use. The index cards read dek, which falls back to description when a post has none. Nine posts already had a dek. On the one that didn't, the old description moved there verbatim, so its index-card teaser didn't change.

  3. Split the SERP title from the h1. Posts can declare an optional seo_title. The post route uses it for <title> only:

    return {
      title: post.seoTitle ?? post.title,
      description: post.description,
      alternates: { canonical: url },
      openGraph: { ...share.openGraph /* built from post.title */ },
      twitter: { ...share.twitter },
    };
    

    The h1, the Open Graph card and the Twitter card keep the full editorial title. Four posts needed one.

  4. Gave the home page its own meta description. SITE.description in lib/site.ts is a long identity blurb that llms.txt and the JSON-LD also use. The home meta tag now reads SITE.metaDescription, so I can fit the snippet without editing the identity copy.

  5. Fixed the outliers. /hire went from 180 characters to 152. /now stopped claiming a cadence. Its first replacement said I was building things "right now" on a page still stamped June 27, which CodeRabbit flagged on PR #49. The follow-up, b9be16a, derives the description from the same UPDATED date the page renders, so the snippet can't claim more freshness than the page shows.

After the change, the commit records 28 rendered pages, 0 over budget, and the h1 identical to the frontmatter title on all ten posts. I re-ran the script below on the build that includes this post, on September 26: 33 prerendered HTML files, the 404 page included, each with a title and a meta description, none over either budget.

Every post on this site is served at two URLs: canonical /writing/<slug>, and the older /aitldr/<slug>, kept so old links still work. The audit recommended adding Disallow: /aitldr/ to robots.txt to keep the duplicates out of the index.

That would have made things worse. Each /aitldr/ page declares rel="canonical" pointing at its /writing/ twin, and a crawler can only read that tag if it's allowed to fetch the page. Block the crawl and Google stops seeing the canonical, while the URLs can still end up indexed. Google's guidance on duplicate URLs says it plainly: don't use robots.txt for canonicalization, because Google may still index disallowed URLs without their content.

It didn't ship. app/robots.ts blocks /api/ and nothing else, and the canonical tag does the consolidating. At fifteen pages, crawl budget was never the constraint.

How to check your own site

  1. Measure the rendered output, not the source. Title templates, fallbacks and defaults all change what ships. Count characters after decoding entities. Byte counts overstate: my audit's 'worst: 293' was a 271-character description. After next build, this reads every prerendered page:

    // measure-meta.mjs, run from the project root after `next build`
    import fs from "node:fs";
    import path from "node:path";
    
    const TITLE_MAX = 60;
    const DESC_MAX = 155;
    const root = ".next/server/app";
    const decode = (s) =>
      s.replace(/&amp;/g, "&").replace(/&#x27;|&#39;/g, "'").replace(/&quot;/g, '"')
       .replace(/&lt;/g, "<").replace(/&gt;/g, ">");
    
    const files = [];
    (function walk(dir) {
      for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
        const p = path.join(dir, e.name);
        if (e.isDirectory()) walk(p);
        else if (e.name.endsWith(".html")) files.push(p);
      }
    })(root);
    
    for (const file of files) {
      const html = fs.readFileSync(file, "utf8");
      const titleMatch = html.match(/<title>([^<]*)<\/title>/);
      const descMatch = html.match(/<meta name="description" content="([^"]*)"/);
      if (!titleMatch || !descMatch) {
        console.log(`Missing or unmatched metadata: ${file.slice(root.length)}`);
        process.exitCode = 1;
        continue;
      }
      const title = decode(titleMatch[1]);
      const desc = decode(descMatch[1]);
      const over = title.length > TITLE_MAX || desc.length > DESC_MAX ? "  <- over" : "";
      console.log(`${title.length}\t${desc.length}\t${file.slice(root.length)}${over}`);
    }
    
  2. Write the description as one complete thought. Lead with the answer or the finding. If it gets cut, the cut should land after the point, not before it.

  3. Separate the fields that want different lengths. Meta description, teaser, identity blurb: if one field feeds all three, one of them is the wrong length.

  4. Give titles an escape hatch. An optional SERP-only title (seo_title ?? title) lets the h1 stay editorial. Count the template suffix.

  5. Read each description as a claim. "Updated weekly" is a promise. If the page can't back it up, cut it.

  6. Canonicalize duplicates with rel="canonical", not robots.txt. The crawler has to fetch the page to see the tag.

  7. Re-run the measurement after every content change. A new post is a new description.

FAQ

How long should a meta description be?

Google doesn't publish a limit. It says there is none, and that snippets are truncated to fit the device width. I budget about 155 characters and write each description as one complete thought inside that. Treat any number, mine included, as a proxy for pixel width.

Why is Google showing a different snippet than my meta description?

Google says snippets are created primarily from page content, and it only sometimes uses the meta description, when that describes the page more accurately. A description that's specific and matches the page gives it a better reason to use yours, but there's no guarantee.

How long should a title tag be?

Same situation: Google truncates title links to fit the device width and doesn't set a character limit. I budget about 60 characters, including the site suffix my title template adds.

Should the SEO title match the H1?

It doesn't have to. Google generates title links automatically, from the page's content (including the <title> element) and from references to the page on the web. I keep the h1 as the full editorial title and add a shorter seo_title only where the full one won't fit. On this site that was four posts out of ten.

How do I set a separate SEO title in the Next.js App Router?

Return it from generateMetadata: title: post.seoTitle ?? post.title. Build the Open Graph and Twitter fields from the full title separately. If your root layout sets title.template, the suffix is added to child pages, so count it against the budget.

Should I block duplicate URLs in robots.txt?

No, if you want them consolidated. Google's guidance is not to use robots.txt for canonicalization. A blocked URL can still be indexed, and the crawler never sees the rel="canonical" on it. Let it crawl the duplicate and point the canonical at the page you want ranked.

This is the same lesson as the CI gate nothing ran: the number that mattered was in the shipped output, not the source file. I only saw it once I measured what actually rendered.