Mert Özen Further With Every Line
Frontend HTML5 and Semantic HTML — #09

The head and Meta Tags: charset, viewport, title, description, Open Graph, theme-color and link rels

Mert Özen Nov 14, 2026 14 min 6 views
The head and Meta Tags: charset, viewport, title, description, Open Graph, theme-color and link rels

A good head starts with meta charset, carries a single viewport that does not disable zoom, a page-specific title and one description, and puts Open Graph tags in the HTML from the server. In this post we measure that a page without a charset breaks Turkish characters, that a phone lays out a page without viewport at 980 pixels, that bots miss Open Graph added with JavaScript, color-scheme, and repeated meta tags in a Razor layout, with the Nu Html Checker and axe.

Version note: This post follows the November 2026 state of the HTML (HyperText Markup Language) Living Standard and Baseline; the examples were tested with Chrome 158, WebKit 27.2 (Playwright 1.65), axe-core 4.14, Nu Html Checker 26.11.9 and .NET 10 (ASP.NET Core Razor Pages).

Guess First

Bookstore's ASP.NET Core layout has passed through several hands over the years. The rendered head of the book detail page looks like this:

{{site/layout-bug.html}}

The page looks fine in the browser. How many problems does this head have? Which viewport applies on a phone, and can the user zoom in with two fingers? Which description does Google see? And which of these does the Nu Html Checker catch?

With the previous post, "Images and Media: img, picture, srcset/sizes, video, audio, track and iframe", we finished the body of the page. This post is about the invisible part: the head. The user never sees it, but whether Turkish characters come out right, whether the page is readable on a phone, the card that appears when a link is shared and the colour of the browser's address bar all depend on a few lines here. The role of title and description in search results is covered in the "Appearing in Results: Title Links, Snippets, Meta Descriptions and Site Names" post of the SEO (Search Engine Optimization) Temelleri series; canonical and hreflang are in the Teknik SEO series. Here we build the HTML side.

The Complete head of the Book Detail Page

First, the target. The head I use for Bookstore's book page:

{{site/book.html}}

The order is not random. charset comes first, because the browser will read the rest with it. viewport right after it, because it affects layout. title and description are the page's identity. Then colour and icon information, then resources: preconnect to open a connection early, the stylesheet and a defer script (script loading strategy is in the "Your First JavaScript Script: The script Tag, defer, async and DevTools" post of the JavaScript Temel series). Open Graph for sharing comes last. Apart from charset, the order is not a strict rule for the browser; but this arrangement makes a missing line easy to spot in a code review.

meta charset and the First 1024 Bytes

To turn bytes into characters, the browser has to know the encoding. It looks in this order: a BOM (Byte Order Mark) at the start of the file, the charset in the HTTP (HyperText Transfer Protocol) Content-Type header, and a meta charset found in the first 1024 bytes of the document. The spec requires the meta charset to end entirely within those 1024 bytes. I tried four pages in two engines, with a server that sends Content-Type without a charset:

const pw = require('playwright');
const http = require('http');
const fs = require('fs');

const page = (beforeTitle, meta = '<meta charset="utf-8">') => `<!doctype html>
<html lang="tr">
<head>
    ${beforeTitle}
    ${meta}
    <title>Kürk Mantolu Madonna | Bookstore</title>
</head>
<body>
    <h1>Kürk Mantolu Madonna</h1>
</body>
</html>`;

const pages = {
  '/meta-first': { html: page(''), type: 'text/html' },
  // a 1100-byte comment before meta charset (e.g. a large inline CSS block)
  '/meta-late': { html: page(`<!-- ${'x'.repeat(1100)} -->`), type: 'text/html' },
  '/no-meta': { html: page('', ''), type: 'text/html' },
  // no meta, but the server states the charset in the header
  '/no-meta-header': { html: page('', ''), type: 'text/html; charset=utf-8' },
};

// also write the same pages to disk for the Nu Html Checker
fs.mkdirSync('site/charset', { recursive: true });
for (const [url, p] of Object.entries(pages)) fs.writeFileSync(`site/charset${url}.html`, p.html);

const server = http.createServer((req, res) => {
  const p = pages[req.url];
  res.setHeader('Content-Type', p.type);
  res.end(Buffer.from(p.html, 'utf8'));
}).listen(18890);

(async () => {
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    const tab = await browser.newPage();
    console.log(`--- ${name} ${browser.version()}`);
    for (const url of Object.keys(pages)) {
      await tab.goto(`http://localhost:18890${url}`);
      const [charset, h1] = await tab.evaluate(() => [document.characterSet, document.querySelector('h1').textContent]);
      console.log(`${url.padEnd(16)} ${charset.padEnd(13)} ${h1}`);
    }
    await browser.close();
  }
  server.close();
})();
--- chromium 158.0.8127.18
/meta-first      UTF-8         Kürk Mantolu Madonna
/meta-late       UTF-8         Kürk Mantolu Madonna
/no-meta         windows-1252  Kürk Mantolu Madonna
/no-meta-header  UTF-8         Kürk Mantolu Madonna
--- webkit 27.2
/meta-first      UTF-8         Kürk Mantolu Madonna
/meta-late       UTF-8         Kürk Mantolu Madonna
/no-meta         windows-1254  Kürk Mantolu Madonna
/no-meta-header  UTF-8         Kürk Mantolu Madonna

The /no-meta line shows the real danger: there is no encoding in the header or in a meta, and both engines fell back to their own default legacy encoding. "Kürk" became "Kürk"; on a Turkish site that means broken headings, names and addresses. The default encoding also differs between engines (windows-1252 and windows-1254 here), which can lead to seeing the bug in the test environment and not in production, or the other way round. On the /no-meta-header line the same page is fine, because the server says charset=utf-8.

/meta-late is interesting: the meta charset ended up behind a 1100-byte comment, yet both engines found the right encoding, UTF-8 from the UTF (Unicode Transformation Format) family. Today's browsers can notice a late declaration and switch the encoding during parsing. That recovery is a tolerance, not a guarantee; another example of the forgiving parser we saw in the first post of the series. The checker flags it as an error (output below), and that is right: make meta charset the first line of the head, and don't put a large inline style, a comment or the title before it.

If you use ASP.NET Core, good news: Razor views and Razor Pages send the response with Content-Type: text/html; charset=utf-8, as you'll see in the curl output in "In Production". Still write the meta charset; you can't rely on the header when the page is saved to disk, when a CDN (Content Delivery Network) changes the header, or when the HTML is served as a static file.

viewport: width=device-width and Not Disabling Zoom

Mobile browsers treat a page that declares no viewport as written for desktop, usually lay it out at 980 pixels wide and shrink it to fit the screen. The result is text too small to read. I measured three variants in a 390-pixel phone emulation and ran axe-core's meta-viewport rule on each:

const { chromium } = require('playwright');
const fs = require('fs');

const book = fs.readFileSync('site/book.html', 'utf8');
const variants = {
  'no viewport': book.replace(/ *<meta name="viewport"[^>]*>\n/, ''),
  'device-width': book,
  'no zoom': book.replace('initial-scale=1', 'initial-scale=1, maximum-scale=1, user-scalable=no'),
};

(async () => {
  const browser = await chromium.launch();
  // emulate a 390-pixel phone (isMobile turns on the viewport meta)
  const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
  for (const [name, html] of Object.entries(variants)) {
    const page = await context.newPage();
    await page.setContent(html, { waitUntil: 'domcontentloaded' });
    await page.addScriptTag({ path: require.resolve('axe-core') });
    const [width, violations] = await page.evaluate(async () => {
      const r = await axe.run(document, { runOnly: ['meta-viewport'] });
      return [window.innerWidth, r.violations.map(v => `${v.id} (${v.impact})`)];
    });
    console.log(`${name.padEnd(13)} innerWidth=${width} axe: ${violations.join(', ') || 'no violations'}`);
  }
  await browser.close();
})();
no viewport   innerWidth=980 axe: no violations
device-width  innerWidth=390 axe: no violations
no zoom       innerWidth=390 axe: meta-viewport (moderate)

Without viewport, the layout width is 980 pixels; with width=device-width it is 390, the device's own width. Media queries in responsive CSS (Cascading Style Sheets) only work correctly on a phone when this line is present. initial-scale=1 sets the initial zoom level to 1.

The third line is maximum-scale=1, user-scalable=no, added so that "the page doesn't zoom by accident on mobile". The layout is the same, but axe found a violation: this setting stops the user from pinch-zooming. For a user with low vision, that means not being able to read the page. WCAG (Web Content Accessibility Guidelines) requires text to be resizable to at least twice its size. According to MDN (MDN Web Docs), Safari has ignored this setting by default since iOS 10, so this line doesn't even work on an iPhone; but it can work on Android. The right line is short: width=device-width, initial-scale=1, and nothing that limits zoom.

title and meta description: The HTML Side

title is one of HTML's required elements: it is in the browser tab, in history, in bookmarks and in the first sentence a screen reader reads when entering the page. A good title starts with the most distinctive information and ends with the site name: "Clean Architecture by Robert C. Martin | Bookstore". Reverse the order and a user with ten tabs open sees "Bookstore | ..." on all of them.

meta name="description" is a short summary of the page and a candidate for the snippet in search results. Its length, and when Google uses it or generates its own snippet, are topics for the SEO Temelleri series. On the HTML side two rules are enough: every page has its own description, and a page has only one. The page in the guessing question has two; which one gets used is up to the search engine, and the checker flags it as an error.

Open Graph and Twitter Cards

The card shown when a book link is shared on WhatsApp, LinkedIn, Slack or X is determined by Open Graph tags. The protocol treats four properties as required: og:title, og:type, og:image and og:url. If there is an og:image, according to ogp.me there should also be an og:image:alt describing it. On our page og:type is book; this type, which the protocol defines for books, takes extra properties such as book:isbn.

For the image, Meta's (Facebook) recommendation is at least 1200×630 pixels, a ratio of about 1.91:1; below 600×315 the card falls back to a small image. Writing og:image:width and og:image:height also helps: according to Meta's documentation, the crawler can then draw the card without waiting to download and process the image, otherwise the first person to share the link may see a card without an image. The crawler caches images by URL (Uniform Resource Locator); when you change the image, give it a new URL. On the X side, choosing the card type with twitter:card (summary_large_image) is enough; for the title, description and image, X falls back to the Open Graph values when there is no twitter:* counterpart.

The most expensive mistake with these tags is adding them with JavaScript. In a client-side rendered application such as Angular, updating the title and meta tags with a service on every route change is easy, but a bot that builds link previews does not run the page like a browser:

{{site/csr.html}}
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  if (!req.url.endsWith('.html')) { res.statusCode = 404; return res.end(); }
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  res.end(fs.readFileSync(`site${req.url}`));
}).listen(18893);

// like a link preview bot: download the HTML, read the meta tags without running JavaScript
const ogTags = html => [...html.matchAll(/<meta property="(og:[^"]+)" content="([^"]*)"/g)].map(m => m[1]);
const title = html => html.match(/<title>([^<]*)<\/title>/)[1];

(async () => {
  const browser = await chromium.launch();
  for (const file of ['/book.html', '/csr.html']) {
    const raw = await (await fetch(`http://localhost:18893${file}`)).text();
    console.log(`${file} raw HTML : title="${title(raw)}" og=[${ogTags(raw).join(', ')}]`);
    const page = await browser.newPage();
    await page.goto(`http://localhost:18893${file}`);
    const dom = await page.content();
    console.log(`${file} browser  : title="${title(dom)}" og=[${ogTags(dom).join(', ')}]`);
    await page.close();
  }
  await browser.close();
  server.close();
})();
/book.html raw HTML : title="Clean Architecture by Robert C. Martin | Bookstore" og=[og:type, og:title, og:description, og:url, og:image, og:image:width, og:image:height, og:image:alt, og:site_name]
/book.html browser  : title="Clean Architecture by Robert C. Martin | Bookstore" og=[og:type, og:title, og:description, og:url, og:image, og:image:width, og:image:height, og:image:alt, og:site_name]
/csr.html raw HTML : title="Bookstore" og=[]
/csr.html browser  : title="Clean Architecture by Robert C. Martin | Bookstore" og=[og:title, og:image]

In the browser both pages look right: the title and Open Graph tags of csr.html are in place after JavaScript runs. But for a bot that only downloads the HTML, the title of csr.html is "Bookstore" and there isn't a single Open Graph tag; the shared link shows up with an empty card. Most link preview bots work this way. The fix is to have these tags in the HTML that comes from the server: in ASP.NET Core, Razor already produces it that way; in Angular you need SSR (Server-Side Rendering) or prerendering. The same principle applies to AI (artificial intelligence) agents and crawlers; see the "Building Sites for AI Agents" post of the GEO (Generative Engine Optimization) series. The page's structured data with JSON-LD (JavaScript Object Notation for Linked Data) is a topic for the upcoming Structured Data ve Schema.org series.

color-scheme and theme-color

meta name="color-scheme" content="light dark" tells the browser "this page supports both themes". Its effect shows up in the browser's own styles, before your CSS:

const { chromium } = require('playwright');
const fs = require('fs');

const book = fs.readFileSync('site/book.html', 'utf8');
const variants = {
  'with color-scheme': book,
  'without': book.replace(/ *<meta name="color-scheme"[^>]*>\n/, ''),
};

(async () => {
  const browser = await chromium.launch();
  for (const [name, html] of Object.entries(variants)) {
    const page = await browser.newPage();
    // the user's operating system is in dark mode
    await page.emulateMedia({ colorScheme: 'dark' });
    await page.setContent(html, { waitUntil: 'domcontentloaded' });
    const r = await page.evaluate(() => {
      const css = el => getComputedStyle(el);
      return `text=${css(document.body).color} input=${css(document.querySelector('input')).backgroundColor}`;
    });
    console.log(`${name.padEnd(18)} ${r}`);
  }
  await browser.close();
})();
with color-scheme  text=rgb(255, 255, 255) input=rgb(59, 59, 59)
without            text=rgb(0, 0, 0) input=rgb(255, 255, 255)

With the user's operating system in dark mode, on the page that declares color-scheme the browser turned the default text white and the input background dark grey. On the page that doesn't, everything stayed in the light theme: seeing glaring white inputs and scrollbars in the middle of a dark design is usually caused by this missing line. Because the line is in the HTML, it also takes effect on the first paint, before the CSS file has arrived. The CSS side of dark mode is in the upcoming CSS3: Flexbox, Grid and Modern CSS series.

theme-color suggests a colour for the browser's UI (address bar, tab strip) and can take separate values for light and dark themes with the media attribute. But support is patchy: according to caniuse data it is partial in Chrome (mostly Android and installed apps), absent in Firefox, and Safari stopped using it on the iPhone with iOS 26. So theme-color is decoration; nothing on the page should depend on it. In an app installed as a PWA (Progressive Web App), the theme_color in the manifest also comes into play; the meta tag overrides it. The manifest itself is covered in the upcoming Browser API'leri (Application Programming Interface) ve PWA series.

Three lines are enough for icons today: a 32×32 favicon.ico for older browsers and tools, a scalable SVG (Scalable Vector Graphics) for modern browsers (which can, if you like, contain a prefers-color-scheme media query), and a 180×180-pixel apple-touch-icon PNG (Portable Network Graphics) for the iOS home screen. Android and installable app icons come from the manifest. The rest of the link elements in the head are topics for other series, not this one; a short map:

relWhat it doesDetails
icon, apple-touch-iconTab, bookmark and home screen iconThis post
stylesheetCSS file; blocks renderingCSS3 series, Web Performance
preconnect, preload, modulepreloadPrepares a connection or resource in advanceWeb Performance
canonicalDeclares the page's main URLTeknik SEO
alternate + hreflangThe page's counterpart in other languagesTeknik SEO
manifestInstallable app informationBrowser API'leri ve PWA

In Production

How does the page in the guessing question come about in an application where all pages share the same layout? A "default" description and viewport go into the layout, then a page adds its own value to the head a second time; or no page provides its own value and the whole site ships with the same description. A Razor Pages layout that prevents both:

{{razor/Pages/Shared/_Layout.cshtml}}
{{razor/Pages/Books/Detail.cshtml}}
{{razor/Pages/Cart.cshtml}}

charset and viewport live only in the layout, once. The description is printed if the page provides one, and not at all otherwise: it is better for it to be missing than for the same sentence to repeat on hundreds of pages, because you can find the missing one with a test. Page-specific tags such as Open Graph come through a section. Two pages with curl:

curl -si http://localhost:5291/books/clean-architecture
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Date: Fri, 13 Nov 2026 22:02:47 GMT
Server: Kestrel
Transfer-Encoding: chunked

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Clean Architecture by Robert C. Martin | Bookstore</title>
        <meta name="description" content="Clean Architecture explains how to keep business rules independent of frameworks &amp; databases.">
    
    <meta property="og:title" content="Clean Architecture">
    <meta property="og:image" content="https://images.bookstore.example/og/clean-architecture.jpg">

</head>
<body>
    <main>
    <h1>Clean Architecture</h1>
</main>

</body>
</html>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Your cart | Bookstore</title>
    
</head>

Two details: the Content-Type header has charset=utf-8, and Razor encoded the & in the description as &amp; inside the attribute. Code that prints the value by concatenating strings by hand doesn't do this, and a quotation mark breaks the attribute. The cart page has no description; for a page like this that you don't want indexed by search engines, that can be a deliberate choice, but for important pages such as the book page, an integration test or a post-build check should catch a missing description.

Finally, the checker. I ran the pages in this post through the Nu Html Checker:

docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --skip-non-html /site
"file:/site/layout-bug.html":10.5-10.121: error: A document must not include more than one “meta” element with its “name” attribute set to the value “description”.
"file:/site/charset/meta-late.html":5.5-5.25: error: A “charset” attribute on a “meta” element found after the first 1024 bytes.

The checker caught two things: the second description and the charset after 1024 bytes. But it did not flag the second viewport from the guessing question, the zoom-disabling maximum-scale=1, or the title coming before charset (on that page charset is still within the first 1024 bytes). Running axe on the same page reveals the missing half:

const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
  const page = await context.newPage();
  await page.setContent(fs.readFileSync('site/layout-bug.html', 'utf8'));
  await page.addScriptTag({ path: require.resolve('axe-core') });
  console.log(await page.evaluate(async () => {
    const r = await axe.run(document, { runOnly: ['meta-viewport'] });
    return r.violations.map(v => `${v.id}: ${v.nodes.map(n => n.html).join(' | ')}`).join('\n') || 'no violations';
  }));
  await browser.close();
})();
meta-viewport: <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">

The two tools answer different questions: the checker answers conformance to the spec, axe answers accessibility. For the head, run both.

Common Pitfalls

First, writing meta keywords. Google announced in 2009 that it doesn't use this tag for ranking in web search; today its only effect is handing your keyword list to competitors.

Second, more than one viewport. The second one in the guessing question disabled zoom, and the checker didn't see it. Keep one in the layout and let pages leave it alone.

Third, printing the same description on every page from the layout. For a search engine, hundreds of identical summaries are no better than none.

Fourth, user-scalable=no or maximum-scale=1. An accessibility violation, and ignored on the iPhone anyway.

Fifth, adding Open Graph tags only on the client side. Bots read without running JavaScript, so the card stays empty.

Sixth, putting a large inline style or the title before meta charset. Today's browsers usually recover; don't rely on it.

A Small Experiment

★☆☆ Open the source (not in DevTools but in View Source, that is, the HTML coming from the server) of the home page and a detail page of a site you work on. Is charset on the first line, how many viewport and description tags are there, is there a value that disables zoom?

★★☆ Adapt crawler.js to three pages of your own site: compare the title and Open Graph tags in the raw HTML with the DOM (Document Object Model) in the browser. If there is a difference, find which page type it is on and why.

★★★ In your Razor or Angular application, write an integration test that verifies, for every page, that a title, a single description, og:title, og:image and og:image:alt are present in the HTML coming from the server. The test should also catch a second viewport accidentally added to the layout.

The page's identity is complete with the head. In the next post we go back to the body and look at one of the most misused elements: table. We'll build Bookstore's order history table with caption, thead, th scope and colgroup, and see how to show it on a narrow screen without breaking table semantics.