How to check meta title and description of a website

Read the two tags from the served HTML rather than from the CMS field. node meta-read.cjs https://host/page prints each title and description with its character count, says when one is absent, and names any value that appears on more than one URL. Character counts are a guide, because results truncate on pixel width.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this on every URL a release adds or moves, and on the whole set of URLs a template generates, which is where duplicates come from. The tags live in the CMS, in a framework head component and sometimes in a CDN rewrite, so the value a tester reads in an editor is not always the value the server sends.

The failure it prevents is quiet. A new section of 40 pages ships with one title, because the template interpolates a variable that is empty on every page but the first. Nothing errors and the release passes review.

Four defects account for most of what this check finds: a repeated title, an absent description, a description a template filled from the body's first paragraph, and two copies of either tag in one document.

Prerequisites

Save this as meta-server.cjs and start it with node meta-server.cjs. The four routes carry the four defects.

// node meta-server.cjs   ->   http://127.0.0.1:8851
// /a/ correct. /b/ duplicate title, no description.
// /c/ description auto-filled from the first paragraph. /d/ two of each tag.
const http = require('http');
const PORT = 8851;
const T = 'Payments API reference: authentication and errors';
const D =
  'Authenticate with a bearer token, send idempotency keys on every write, and read the error code from the JSON body. Covers all 4xx and 5xx responses.';
const P =
  'A refund returns money to the original payment method. Refunds are asynchronous: the API accepts the request, the processor settles it, and the webhook confirms it. Partial refunds are allowed until the captured amount is exhausted.';

const heads = {
  '/a/': `<title>${T}</title>\n<meta name="description" content="${D}">`,
  '/b/': `<title>${T}</title>`,
  '/c/': `<title>Refunds: issue a refund on the Payments API</title>\n<meta name="description" content="${P}">`,
  '/d/': `<title>${T}</title>\n<meta name="description" content="${D}">\n<title>Payments</title>\n<meta name="description" content="Short editorial description.">`,
};

http.createServer((req, res) => {
  const head = heads[req.url] || heads['/a/'];
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(`<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n${head}\n</head>\n<body><p>${P}</p></body>\n</html>\n`);
}).listen(PORT, '127.0.0.1', () => console.log(`meta server on ${PORT}`));

Save this as meta-read.cjs. It takes any number of URLs and compares them with each other.

// node meta-read.cjs <url> [url...]
// Reads the served title and meta description of each URL, counts them,
// reports their length, and names any title or description used twice.
const seen = { title: new Map(), description: new Map() };
const decode = (s) =>
  s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");

(async () => {
  for (const url of process.argv.slice(2)) {
    const html = await (await fetch(url)).text();
    const head = (/<head[\s\S]*?<\/head>/i.exec(html) || [html])[0];
    const titles = [...head.matchAll(/<title[^>]*>([\s\S]*?)<\/title>/gi)].map((m) => decode(m[1].trim()));
    const descs = [...head.matchAll(/<meta[^>]+name=["']?description["']?[^>]*>/gi)]
      .map((m) => decode(((/content=["']([^"']*)["']/i.exec(m[0]) || [])[1] || '').trim()));
    console.log(url);
    for (const [tag, list] of [['title', titles], ['description', descs]]) {
      if (!list.length) console.log(`  ${tag.padEnd(11)} n=0  absent`);
      for (const [i, v] of list.entries()) {
        console.log(`  ${tag.padEnd(11)} n=${i + 1}  ${String(v.length).padStart(3)} chars  ${v.slice(0, 52)}${v.length > 52 ? '...' : ''}`);
        seen[tag].set(v, [...(seen[tag].get(v) || []), url]);
      }
    }
    const p = decode(((/<p[^>]*>([\s\S]*?)<\/p>/i.exec(html) || [])[1] || '').replace(/<[^>]+>/g, '').trim());
    if (descs[0] && p && p.startsWith(descs[0].slice(0, 60)))
      console.log('  description is the opening paragraph of the body, not written copy');
  }
  for (const tag of ['title', 'description'])
    for (const [v, urls] of seen[tag]) if (urls.length > 1) console.log(`DUPLICATE ${tag} on ${urls.join(' and ')}: ${v.slice(0, 40)}...`);
})();

Save these as dom-tags.mjs and title-px.mjs. Both drive Chrome through the session helper.

// node dom-tags.mjs <url>
// What the browser exposes when a document carries two titles and two descriptions.
import { open } from '../../scripts/browser/session.mjs';

const s = await open();
try {
  await s.goto(process.argv[2]);
  const r = await s.page.evaluate(() => ({
    titles: [...document.querySelectorAll('title')].map((t) => t.textContent),
    documentTitle: document.title,
    descs: [...document.querySelectorAll('meta[name="description"]')].map((m) => m.content),
  }));
  console.log('title elements in DOM   ' + r.titles.length + '  ' + JSON.stringify(r.titles));
  console.log('document.title          ' + JSON.stringify(r.documentTitle));
  console.log('description elements    ' + r.descs.length + '  ' + JSON.stringify(r.descs.map((d) => d.slice(0, 40))));
  console.log('chrome ' + (await s.browser.version()));
} finally {
  await s.close();
}
// node title-px.mjs "<title>" ["<title>" ...]
// Characters against rendered pixel width, measured by Chrome in Arial 20px.
import { open } from '../../scripts/browser/session.mjs';

const strings = process.argv.slice(2);
const s = await open();
try {
  await s.goto('about:blank');
  const px = await s.page.evaluate((list) => {
    const c = document.createElement('canvas').getContext('2d');
    c.font = '20px Arial';
    return list.map((t) => Math.round(c.measureText(t).width));
  }, strings);
  console.log('chars   px  title');
  strings.forEach((t, i) => console.log(`${String(t.length).padStart(5)} ${String(px[i]).padStart(4)}  ${t}`));
} finally {
  await s.close();
}

Steps

  1. Step 1.

    Read two public pages first, to see what the wire gives you.

    node meta-read.cjs https://example.com/ https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    https://example.com/
    title       n=1   14 chars  Example Domain
    description n=0  absent
    https://developer.mozilla.org/en-US/docs/Web/HTTP
    title       n=1   39 chars  HTTP: Hypertext Transfer Protocol | MDN
    description n=1  287 chars  HTTP is an application-layer protocol for transmitti...

    A documentation page ships a 287-character description. That is past every rule of thumb and it is not a defect: length is an input to truncation, not a pass or fail.

  2. Step 2.

    Read the four local routes in one command, so the duplicate check has something to compare.

    node meta-read.cjs http://127.0.0.1:8851/a/ http://127.0.0.1:8851/b/ http://127.0.0.1:8851/c/ http://127.0.0.1:8851/d/
    
    http://127.0.0.1:8851/a/
    title       n=1   49 chars  Payments API reference: authentication and errors
    description n=1  149 chars  Authenticate with a bearer token, send idempotency k...
    http://127.0.0.1:8851/b/
    title       n=1   49 chars  Payments API reference: authentication and errors
    description n=0  absent
    http://127.0.0.1:8851/c/
    title       n=1   43 chars  Refunds: issue a refund on the Payments API
    description n=1  232 chars  A refund returns money to the original payment metho...
    description is the opening paragraph of the body, not written copy
    http://127.0.0.1:8851/d/
    title       n=1   49 chars  Payments API reference: authentication and errors
    title       n=2    8 chars  Payments
    description n=1  149 chars  Authenticate with a bearer token, send idempotency k...
    description n=2   28 chars  Short editorial description.
    DUPLICATE title on http://127.0.0.1:8851/a/ and http://127.0.0.1:8851/b/ and http://127.0.0.1:8851/d/: Payments API reference: authentication a...
    DUPLICATE description on http://127.0.0.1:8851/a/ and http://127.0.0.1:8851/d/: Authenticate with a bearer token, send i...

    Four defects, four shapes. /b/ has no description. /c/ has one matching the body's opening paragraph, which is what a template does when the editorial field is empty. /d/ carries two of each tag. The DUPLICATE lines need more than one URL, which is why this check runs on a set.

  3. Step 3.

    Ask the browser which of the two copies it uses.

    node dom-tags.mjs http://127.0.0.1:8851/d/
    
    title elements in DOM   2  ["Payments API reference: authentication and errors","Payments"]
    document.title          "Payments API reference: authentication and errors"
    description elements    2  ["Authenticate with a bearer token, send i","Short editorial description."]
    chrome Chrome/152.0.7977.76

    Chrome keeps both elements and resolves document.title to the first. A checker that takes the last match of a regular expression reports the second. Both readings are defensible, so two copies of a tag leave the page with no single correct answer. Fail it.

  4. Step 4.

    Measure what actually decides truncation.

    node title-px.mjs "Payments API reference: authentication and errors" "Fitting a split rail: limits, lift, trim and filing" "Workwear Warehouse: modern wool wraps and overcoats"
    
    chars   px  title
     49  449  Payments API reference: authentication and errors
     51  362  Fitting a split rail: limits, lift, trim and filing
     51  512  Workwear Warehouse: modern wool wraps and overcoats

    The second and third titles are both 51 characters. Rendered at the same font and size, one is 362 px wide and the other 512 px, a difference of 150 px. A character budget is off by that much on the first title with wide letters in it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | title n=1 with a value unique to the URL | Nothing to fix here | Move on to the description | | DUPLICATE title on ... and ... | One template renders the same title for many URLs | Interpolate a field that differs per page, then re-run on the whole set | | description n=0 absent | The search engine writes its own snippet from the page | Not an error. Write one when the snippet you want differs from the body | | description is the opening paragraph | The editorial field was empty and a fallback filled it | Treat as missing, because nobody chose it | | title n=2 or description n=2 | Two copies in one document | Fail it. Which one a reader takes is not defined by the markup |

Thresholds

Two titles of 51 characters each measured 362 px and 512 px when rendered in Arial at 20 px. A character count carries a 150 px error at that length, so use it as a guide and read the rendered width when the title matters. Source: Measured with Chrome 152.0.7977.76 on 2026-09-11, step 4, see the Verified block

Common mistakes

Sign: A title within the character budget is still cut short in results.Cause: Truncation happens at a pixel width, not a character count, and capital letters and wide glyphs spend that budget faster. Step 4 measures 150 px of difference between two titles of identical length. The same title also renders differently on a phone, and search engines rewrite titles they consider unhelpful, so the tag is an input rather than a guarantee.
Sign: The description is present on every page and reads as a sentence fragment cut off mid-clause.Cause: A template filled the tag from the first paragraph of the body because the editorial field was empty. Every presence check passes. Compare the description with the opening paragraph, as the reader in step 2 does, and treat a match as a missing description.
Sign: Two tools disagree about the title of the same URL.Cause: The document has two title elements, usually a framework default plus a page override that was appended instead of replacing. Chrome resolves document.title to the first, while a tool matching the last occurrence returns the second. Neither is wrong, and the page ships with an undefined answer until one copy is removed.
Sign: The CMS shows a unique title for every page and the crawl report lists them all as identical.Cause: The value is read from the served HTML, not from the editor. A head component that ignores the field, a cached page, or a CDN rewrite all produce this. Fetch the URL rather than opening the admin screen.

What to check next

FAQ

How to check meta description?

Fetch the URL and read the tag from the response, as in step 2. Reading it in the CMS confirms what was entered, not what was served, and the two diverge whenever a head component, a cache or a CDN rewrite sits between them.

How to check title tag?

The same request answers both. Count the title elements before reading the value, because a document with two of them gives different tools different answers, as step 3 shows.

What is the difference between meta name title and the title tag?

The <title> element is the document title, defined by the HTML specification and used by browsers, tabs and search results. A <meta name="title"> tag is not part of that definition and is ignored by Google. Pages that carry both usually got the meta tag from a plugin.

How long should a title be?

Long enough to name the page, and written so the distinguishing words come first, because the end is what gets cut. No character count answers this: step 4 measured two 51-character titles 150 px apart. Measure the rendered width of the titles you ship.

Does a missing description hurt the page?

No. A search engine generates a snippet from the page content instead, often better matched to the query than a written one. Write the tag when you need control of the wording, for example on a page whose body opens with a legal notice.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic6 minpublished updated Maks Verny