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
- Node 18 or later, for the reader and the local target below.
- Chrome for steps 3 and 4. Both browser figures are one capture on one machine.
- The HTML specification for the title element, which defines the document title as the first such element.
- The Meta tag checker when one URL is all you need.
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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
- 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/HTTPhttps://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.
- 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. TheDUPLICATElines need more than one URL, which is why this check runs on a set. - 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.76Chrome keeps both elements and resolves
document.titleto 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. - 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 overcoatsThe 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
Common mistakes
What to check next
- How to check if a page is indexable: a title matters only on a page that can enter an index.
- How to check canonical tag: the tag that decides which of several duplicates the title belongs to.
- How to check heading structure of a page: the H1 the title is often confused with, and why they differ.
- How to check viewport meta tag: another head tag whose absence changes rendering rather than raising an error.
- How to check if a staging site is indexable: where duplicate titles across environments start.
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.
Related on this site
- Checker: meta-tags title, description, canonical, robots meta, hreflang, Open Graph, Twitter card, viewport
- All crawlability and indexing checks
basic6 minpublished updated Maks Verny