Project: An E-commerce Listing Page

Filters, sorting, pagination and a product grid. The page where canonical tags, crawl budget and accessible dynamic updates all become real problems.

The brief

Build a category listing page: a filter sidebar, a sort control, a product grid and pagination. It is the page where SEO decisions and accessibility decisions interact most.

The structure

<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/kitchen">Kitchen</a></li>
    <li><a href="/kitchen/kettles" aria-current="page">Kettles</a></li>
  </ol>
</nav>

<main id="main-content" tabindex="-1">
  <h1>Kettles</h1>
  <p>Electric and stovetop kettles, all with a two year warranty.</p>

  <div class="listing">
    <form class="filters" action="/kitchen/kettles" method="get">
      <h2>Filter</h2>

      <fieldset>
        <legend>Capacity</legend>
        <p><label><input type="checkbox" name="capacity" value="1l"> Up to 1 litre</label></p>
        <p><label><input type="checkbox" name="capacity" value="2l"> 1 to 2 litres</label></p>
      </fieldset>

      <fieldset>
        <legend>Price</legend>
        <p>
          <label for="price-max">Maximum price</label>
          <input type="number" id="price-max" name="price_max"
                 min="0" max="10000" step="500" value="10000">
        </p>
      </fieldset>

      <button type="submit">Apply filters</button>
      <a href="/kitchen/kettles">Clear all</a>
    </form>

    <section aria-labelledby="results-heading">
      <div class="results-bar">
        <h2 id="results-heading">
          <span aria-live="polite">24 kettles</span>
        </h2>

        <form action="/kitchen/kettles" method="get" class="sort">
          <label for="sort">Sort by</label>
          <select id="sort" name="sort">
            <option value="relevance" selected>Relevance</option>
            <option value="price-asc">Price, lowest first</option>
            <option value="price-desc">Price, highest first</option>
          </select>
          <button type="submit">Sort</button>
        </form>
      </div>

      <ul class="product-grid" role="list">
        <li>
          <article class="product-card">
            <img src="/images/kettle-2l-400.jpg"
                 srcset="/images/kettle-2l-400.jpg 400w, /images/kettle-2l-800.jpg 800w"
                 sizes="(max-width: 50rem) 50vw, 16rem"
                 alt="" width="400" height="400" loading="lazy">
            <h3><a href="/products/kettle-2l">Two litre electric kettle</a></h3>
            <p class="price">
              <span class="visually-hidden">Price:</span>
              <s>Rs 2,999</s> <strong>Rs 2,499</strong>
            </p>
            <p class="rating">Rated 4.4 out of 5 from 87 reviews</p>
          </article>
        </li>
        <!-- more products -->
      </ul>

      <nav aria-label="Pagination">
        <ol>
          <li><a href="?page=1" rel="prev">Previous</a></li>
          <li><a href="?page=1">1</a></li>
          <li><a href="?page=2" aria-current="page">2</a></li>
          <li><a href="?page=3">3</a></li>
          <li><a href="?page=3" rel="next">Next</a></li>
        </ol>
      </nav>
    </section>
  </div>
</main>

The SEO problems this page creates

Filter combinations multiply URLs

/kitchen/kettles
/kitchen/kettles?capacity=1l
/kitchen/kettles?capacity=1l&price_max=3000
/kitchen/kettles?capacity=1l&price_max=3000&sort=price-asc
/kitchen/kettles?sort=price-asc&capacity=1l&price_max=3000
... and so on, effectively without limit

Every combination is a URL, and the last two are the same page with the parameters in a different order. Left alone, this consumes crawl budget on thousands of near duplicate pages.

Two measures together:

<!-- on any filtered or sorted view -->
<link rel="canonical" href="https://example.com/kitchen/kettles">
# robots.txt - stop the crawler fetching them at all
User-agent: *
Disallow: /*?capacity=
Disallow: /*?price_max=
Disallow: /*?sort=

Pagination canonicals

<!-- on page 2: canonical points at page 2, not page 1 -->
<link rel="canonical" href="https://example.com/kitchen/kettles?page=2">

Pointing every page at page one is a common and damaging error: pages two onwards are then never indexed, and the products listed only on them become harder to discover.

The filter form uses GET

Filtering is a question, not a change, so GET is correct. It also means a filtered view has a URL that can be bookmarked and shared - which POST would prevent.

The accessibility problems this page creates

Announcing the result count

<h2 id="results-heading">
  <span aria-live="polite">24 kettles</span>
</h2>

When filtering happens without a page load, a sighted reader sees the grid change. A screen reader user is told nothing unless the count is in a live region.

Focus after filtering

function afterFilter(count) {
  document.getElementById("result-count").textContent = `${count} kettles`;

  const heading = document.getElementById("results-heading");
  heading.setAttribute("tabindex", "-1");
  heading.focus();          // put the reader at the top of the new results
}

The filter must work without JavaScript

The form has a real action and a real submit button. Enhance it with a script that filters in place if you like, but the page must not depend on that.

Product cards

Empty alt on the image, because the heading beside it names the product. The link is inside the heading, so it has meaningful text and a place in the outline. The price uses a hidden label and an s element so it is announced sensibly rather than as two unexplained numbers.

The grid

.listing {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}

@media (min-width: 56rem) {
  .listing { grid-template-columns: 16rem minmax(0, 1fr); }
}

.product-grid {
  list-style: none;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  gap: 1.5rem;
}

.product-card { position: relative; height: 100%; }
.product-card h3 a::after { content: ""; position: absolute; inset: 0; }

Mobile filters

On a phone the filter form should be behind a button rather than pushed above the products.

<button type="button" class="filter-toggle"
        aria-expanded="false" aria-controls="filter-form">
  Filter and sort
</button>

Keep the form in the markup before the results so a reader who opens it does not have to hunt; hide it with hidden so its controls leave the tab order when closed.

Checking your work

  1. Validate.
  2. Filter with JavaScript disabled. Does it still work?
  3. Filter with a screen reader. Is the new count announced?
  4. Check the canonical tag on a filtered view and on page two.
  5. Confirm one tab stop per product card.
  6. Check which image variant is fetched at three widths.
  7. Confirm the pagination links are real links with real URLs.

Extensions

  • Add ItemList structured data for the products.
  • Update the URL as filters change with the History API, so the view is shareable.
  • Add a chip for each active filter, each removable.
  • Add infinite scroll and keep working pagination links underneath it.
  • Add a comparison feature using checkboxes.

Practice

  1. Build the page and confirm the filter works with scripting disabled.
  2. Add canonical tags for filtered views and for pagination, and explain each choice.
  3. Write the robots.txt rules for the filter parameters.
  4. Filter with a screen reader and confirm the result count is announced.

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.