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
- The structure
- The SEO problems this page creates
- Filter combinations multiply URLs
- Pagination canonicals
- The filter form uses GET
- The accessibility problems this page creates
- Announcing the result count
- Focus after filtering
- The filter must work without JavaScript
- Product cards
- The grid
- Mobile filters
- Checking your work
- Extensions
- Practice
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
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 limitEvery 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
- Validate.
- Filter with JavaScript disabled. Does it still work?
- Filter with a screen reader. Is the new count announced?
- Check the canonical tag on a filtered view and on page two.
- Confirm one tab stop per product card.
- Check which image variant is fetched at three widths.
- Confirm the pagination links are real links with real URLs.
Extensions
- Add
ItemListstructured 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
- Build the page and confirm the filter works with scripting disabled.
- Add canonical tags for filtered views and for pagination, and explain each choice.
- Write the
robots.txtrules for the filter parameters. - Filter with a screen reader and confirm the result count is announced.