Project: A Multi Page Business Website

Six pages sharing a layout, with consistent navigation, breadcrumbs, a sitemap and metadata that differs correctly on every page.

The brief

Build a six page site for a small business. The challenge is not any single page - it is keeping structure, navigation and metadata consistent across all of them while making each page genuinely distinct.

The pages

/                    home
/services            what the business does
/services/repairs    one service in detail
/about               the business and its people
/contact             address, map, form
/404                 not found

The shared layout

Everything except the main content is identical on every page. In a static site that means partials; on a server it means a template.

<!DOCTYPE html>
<html lang="en-IN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- these four differ on every page -->
  <title>{{ page_title }} - Riverside Repairs</title>
  <meta name="description" content="{{ page_description }}">
  <link rel="canonical" href="https://example.com{{ page_path }}">
  <meta property="og:title" content="{{ page_title }}">
  <meta property="og:description" content="{{ page_description }}">
  <meta property="og:url" content="https://example.com{{ page_path }}">
  <meta property="og:image" content="https://example.com/images/og-default.jpg">
  <meta property="og:type" content="website">

  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="/styles/site.css">
  <script src="/scripts/site.js" defer></script>
</head>
<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>

  <header class="site-header">
    <a href="/" class="logo">
      <img src="/logo.svg" alt="Riverside Repairs home" width="160" height="40">
    </a>

    <button type="button" class="menu-toggle"
            aria-expanded="false" aria-controls="site-menu">
      <span class="hamburger" aria-hidden="true"></span>
      Menu
    </button>

    <nav id="site-menu" aria-label="Main">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/services">Services</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/contact">Contact</a></li>
      </ul>
    </nav>
  </header>

  {{ breadcrumb }}

  <main id="main-content" tabindex="-1">
    {{ content }}
  </main>

  <footer class="site-footer">
    <nav aria-label="Footer">
      <ul>
        <li><a href="/privacy">Privacy</a></li>
        <li><a href="/sitemap">Sitemap</a></li>
      </ul>
    </nav>

    <address>
      Riverside Repairs, 14 Sadar Bazaar Road, Pune 411001<br>
      <a href="tel:+912026001234">+91 20 2600 1234</a>
    </address>

    <p><small>Copyright 2026 Riverside Repairs</small></p>
  </footer>
</body>
</html>

Marking the current page

<li><a href="/services" aria-current="page">Services</a></li>
.site-header nav a[aria-current="page"] {
  font-weight: 600;
  border-bottom: 2px solid currentColor;
}

One attribute, announced as current page and styled without an extra class. Whatever generates the pages should set it automatically from the current path.

The mobile menu

const toggle = document.querySelector(".menu-toggle");
const menu   = document.getElementById("site-menu");

toggle.addEventListener("click", () => {
  const open = toggle.getAttribute("aria-expanded") === "true";
  toggle.setAttribute("aria-expanded", String(!open));
  menu.hidden = open;
});

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && toggle.getAttribute("aria-expanded") === "true") {
    toggle.setAttribute("aria-expanded", "false");
    menu.hidden = true;
    toggle.focus();
  }
});
/* the menu is always visible above the breakpoint */
@media (min-width: 48rem) {
  .menu-toggle { display: none; }
  #site-menu[hidden] { display: block; }
}

That last rule matters. The hidden attribute is what removes the links from the tab order on mobile; on desktop the CSS overrides it so the menu shows regardless of the attribute state.

Metadata per page

PageTitleh1
/Riverside Repairs - Appliance Repair in PuneAppliance repair in Pune
/servicesServices - Riverside RepairsWhat we repair
/services/repairsKettle and Small Appliance Repair - Riverside RepairsKettle and small appliance repair
/aboutAbout Us - Riverside RepairsAbout Riverside Repairs
/contactContact - Riverside RepairsContact us

Note that the title and the h1 are related but not identical. The title carries the site name for a search result; the h1 does not need to, because the header already shows it.

The contact page

<h1>Contact us</h1>

<section>
  <h2>Where we are</h2>
  <address>
    14 Sadar Bazaar Road<br>
    Pune 411001<br>
    Maharashtra
  </address>

  <iframe src="https://www.openstreetmap.org/export/embed.html?bbox=73.85,18.51,73.87,18.53"
          title="Map showing the workshop on Sadar Bazaar Road"
          width="100%" height="360" loading="lazy" style="border:0"></iframe>

  <p><a href="https://www.openstreetmap.org/?mlat=18.52&mlon=73.86">Open a larger map</a></p>
</section>

<section>
  <h2>Opening hours</h2>
  <table>
    <caption>Workshop opening hours</caption>
    <tbody>
      <tr><th scope="row">Monday to Friday</th><td>09:00 to 18:00</td></tr>
      <tr><th scope="row">Saturday</th><td>09:00 to 13:00</td></tr>
      <tr><th scope="row">Sunday</th><td>Closed</td></tr>
    </tbody>
  </table>
</section>

The address is written in text as well as shown on a map. A map in a frame is close to unusable with a keyboard and impossible to read aloud.

LocalBusiness structured data

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "LocalBusiness",
  "name": "Riverside Repairs",
  "url": "https://example.com",
  "telephone": "+91-20-2600-1234",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "14 Sadar Bazaar Road",
    "addressLocality": "Pune",
    "postalCode": "411001",
    "addressCountry": "IN"
  },
  "openingHoursSpecification": [
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
      "opens": "09:00",
      "closes": "18:00"
    },
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": "Saturday",
      "opens": "09:00",
      "closes": "13:00"
    }
  ]
}
</script>

The 404 page

<main id="main-content" tabindex="-1">
  <h1>That page is not here</h1>
  <p>It may have moved, or the address may have a typo in it.</p>

  <h2>Try one of these</h2>
  <ul>
    <li><a href="/services">What we repair</a></li>
    <li><a href="/contact">Contact us</a></li>
  </ul>

  <search>
    <form action="/search" method="get">
      <label for="q">Search the site</label>
      <input type="search" id="q" name="q">
      <button type="submit">Search</button>
    </form>
  </search>
</main>

The page must return a real 404 status, not a 200. A soft 404 - an error page returning success - leaves search engines indexing pages that do not exist.

robots.txt and sitemap

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://example.com/</loc><lastmod>2026-08-11</lastmod></url>
  <url><loc>https://example.com/services</loc></url>
  <url><loc>https://example.com/services/repairs</loc></url>
  <url><loc>https://example.com/about</loc></url>
  <url><loc>https://example.com/contact</loc></url>
</urlset>

Checking your work

  1. Validate every page.
  2. Confirm every title, description and canonical is unique and correct.
  3. Tab through each page; the skip link should be first and the menu operable.
  4. Open the mobile menu with a keyboard and close it with Escape.
  5. Request a nonexistent URL and confirm the status is 404, not 200.
  6. Check every internal link with a link checker.
  7. Test the structured data.
  8. Confirm no page is more than two clicks from the home page.

Extensions

  • Add a news section with an index and article pages.
  • Add a language version with reciprocal hreflang.
  • Add a print stylesheet for the contact page.
  • Add a service worker for basic offline support.

Practice

  1. Build all six pages from one shared layout.
  2. Generate the sitemap from the same page list the navigation uses.
  3. Set aria-current automatically from the current path.
  4. Deploy and verify the production robots.txt and 404 status.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.