Define optimized HTML markup

Structure and automate your HTML markup, with techniques, structured data and best practices.

Blueprint of a multi-storey building

Write your HTML once, as a template in which every tag is tied to a data field and has a fallback. In programmatic SEO, a missing canonical or a duplicated H1 is never a single-page bug. Get the template right and thousands of pages are right with it.

Here is the template for the illustrative job board from the zoning chapter, in Handlebars-style syntax. The rest of this chapter explains the choices in it.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{metaTitle}}</title>
  <meta name="description" content="{{metaDescription}}">
  {{#if isIndexable}}
  <link rel="canonical" href="{{canonicalUrl}}">
  {{else}}
  <meta name="robots" content="noindex, follow">
  {{/if}}
</head>
<body>
  <header><!-- site navigation --></header>

  <nav aria-label="Breadcrumb">
    <ol>
      <li><a href="/">Home</a></li>
      <li><a href="/jobs/{{roleSlug}}">{{roleName}} jobs</a></li>
      <li aria-current="page">{{cityName}}</li>
    </ol>
  </nav>

  <main>
    <h1>{{roleName}} jobs in {{cityName}}</h1>
    <p>{{liveJobs}} open positions, updated {{updatedAt}}.</p>

    <section>
      <h2>Latest {{roleName}} openings</h2>
      <ul>
        {{#each jobs}}
        <li><a href="{{url}}">{{title}}</a>, {{employer}}</li>
        {{/each}}
      </ul>
    </section>

    {{#if hasPayData}}
    <section>
      <h2>What {{roleName}} roles pay in {{cityName}}</h2>
      <p>Typical advertised pay: {{medianPay}}.</p>
    </section>
    {{/if}}
  </main>

  <footer><!-- contact, legal pages --></footer>
</body>
</html>

Serve this as HTML from the server or at build time, as a static generator like Astro does. Google can render JavaScript, but you should not count on every crawler doing so, and that includes systems gathering content for AI-generated answers.

Headings that describe the page

The H1 is unique per page, carries the main query pattern and opens the content. H2s divide the main sections, and H3s divide those sections further. H4 to H6 are for genuinely detailed content, such as subsections of a long specification, and most generated pages never need them.

Two habits keep this clean at scale. Choose the heading level for its place in the outline, and let CSS handle size. And vary the H2s with data, as in the template, so a page’s outline says something about that role and that city instead of repeating “Our offers” 3,000 times.

Use the semantic elements too: main, nav, section, ul, ol. They cost nothing, help assistive technology, and make it easy for any parser to separate your content from the page furniture.

Titles and descriptions built from rules

There’s no official character limit for titles. Google truncates by display width, and it may rewrite the title link if it thinks another text describes the page better. Around 60 characters is a sensible target for titles and around 150 to 160 for descriptions. The real difficulty is that variables have wildly different lengths, so build a fallback:

function buildTitle({ role, city, jobCount }) {
  const full = `${jobCount} ${role} jobs in ${city} | HospitalityJobs`;
  return full.length <= 60 ? full : `${role} jobs in ${city}`;
}

This works in a build script as well as in Google Apps Script if your data lives in a Sheet. For descriptions, include a value that changes per page, such as the job count or the top employer. A description identical across pages apart from one word gets rewritten or ignored, and earns few clicks either way.

Indexing rules driven by data

Don’t apply robots directives by hand. Derive them from the page’s data state:

Page state Robots Canonical In the sitemap
Enough live jobs No tag needed (indexable by default) Itself Yes
Below your threshold noindex, follow None No
Sorted or filtered URL, such as ?sort=date No tag needed The clean URL No

index, follow is the default, so writing it adds nothing. Remember that a page blocked in robots.txt can’t show its noindex to Google, because Google never fetches it. Use one or the other depending on the goal. The full directive list is in Google’s robots meta tag documentation. Launch sequencing belongs to the production and indexing chapter.

Hand working over floor plans on a desk

Images that describe something

Use descriptive file names (line-cook-kitchen-lyon.webp rather than image123.jpg) and write alt text that says what the image shows. Generating “line cook jobs in Lyon” as the alt of every image repeats a keyword and tells a screen reader user nothing. If an image is purely decorative, use alt="".

Lazy loading saves bandwidth, with one exception. The main image at the top of the page should load immediately, or you slow down your Largest Contentful Paint:

<img src="/img/hero.webp" alt="Chef plating a dish in a Lyon restaurant"
     width="1200" height="630" fetchpriority="high">

<img src="/img/employer.webp" alt="Dining room of the restaurant"
     width="600" height="400" loading="lazy">

Explicit width and height reserve the space and prevent layout shift.

Internal links must be real <a href> elements. A click handler on a div works for a mouse and leaves crawlers with nothing to follow. Contextual links and breadcrumbs both need that form, and the breadcrumb in the template doubles as navigation for users.

For external links, link to reliable sources when they back up something on the page. I would not use nofollow to hoard authority. That tactic stopped making sense long ago, and Google treats rel values as hints. Use rel="sponsored" for paid links, rel="ugc" for user-submitted links, and nofollow when you really don’t want to vouch for a destination.

Pencil and ruler on a technical drawing

Structured data that matches the page

Pick the type that fits the page: Article or BlogPosting for editorial content, Product with Offer for product pages, BreadcrumbList nearly everywhere, and for our example JobPosting on individual job pages only, since Google’s guidelines exclude pages that list several jobs. Generate the values from the same fields as the visible content:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://www.example.com/" },
    { "@type": "ListItem", "position": 2, "name": "Line cook jobs", "item": "https://www.example.com/jobs/line-cook" },
    { "@type": "ListItem", "position": 3, "name": "Lyon" }
  ]
}
</script>

FAQPage remains valid schema.org vocabulary, but don’t plan on a rich result from it: Google limited those to authoritative government and health sites in 2023, and removed HowTo rich results as well. Structured data still makes your page easier for machines to read, including those composing AI-generated answers. The microdata chapter covers each type in detail.

Automate the markup, then check it

The placeholders above can be filled from wherever your data lives. On small projects, Google Sheets with Apps Script generates titles and descriptions per row. A CMS API on Webflow or WordPress lets a script push those fields to each item. On a custom stack, Astro or any static generator builds them at compile time.

Automation also repeats mistakes faithfully, so check what comes out. Crawl a sample and search the rendered HTML for leaks such as {{, undefined, NaN or empty <h1> tags. Confirm titles are unique, test a few URLs in the Rich Results Test and with Search Console’s URL Inspection, and read five pages as a visitor would. Page usefulness comes from the data and the zoning. Clean markup only ensures that usefulness reaches the machine.

Your next step

Write your template with real variable names from your database, then render your richest and poorest eligible pages. Run both through the checks above and fix anything that leaks. Then define your structured data types, field by field, before you wire the template into production.