Every Headless / Custom (I build in code) fix we catalog

SEOLZ catalogs 87 fixes for Headless / Custom (I build in code) across 5 areas — SEO, answer-engine readiness, accessibility, security and site-health. Each lists the exact steps for Headless / Custom (I build in code), with a link to the official docs.

SEO · 21 fixes

Cwv inpLarger project

Reduce Interaction to Next Paint (INP) to under 200 ms by breaking up long JavaScript tasks, deferring non-critical third-party scripts, and lightening event handlers so the page feels fast and responsive after every tap or click.

On Headless / Custom (I build in code)

  1. Run a Chrome DevTools Performance trace on your highest-traffic interaction (add-to-cart, search, menu open). Find all Long Tasks (>50 ms) that overlap with the interaction.
  2. Refactor synchronous work in event handlers: use `scheduler.yield()` (or a polyfill using `setTimeout`) to break long tasks into smaller chunks, yielding back to the browser between each chunk so it can paint.
  3. Move all non-critical third-party script loading to after the `load` event or after the first user interaction using dynamic `import()` or a lightweight loader.
  4. Audit your framework's hydration strategy (Next.js, Nuxt, Astro, etc.). Use Partial Hydration, Islands Architecture, or React 18's `startTransition` / `useDeferredValue` to avoid blocking the main thread on page load.
  5. Add `scheduler.postTask()` for work that is truly low-priority (analytics, pre-fetching) to ensure it never competes with interaction responses.
  6. Monitor real-user INP with the `web-vitals` JavaScript library, send results to your analytics platform, and set up an alert when p75 INP exceeds 200 ms.
Low performance scoreLarger project

Improve your Lighthouse/PageSpeed performance score by reducing page weight, eliminating render-blocking resources, optimising images, and addressing Core Web Vitals so Google ranks your store higher in search results.

On Headless / Custom (I build in code)

  1. Run Lighthouse in CI (GitHub Actions or similar) on every deploy using the lighthouse-ci npm package so regressions are caught automatically.
  2. Implement Next.js Image component (next/image) or equivalent framework image optimisation — it handles WebP conversion, lazy loading, and prevents CLS via reserved space automatically.
  3. Code-split aggressively: use dynamic imports for non-critical components (modals, chat, reviews carousels) so the initial JS bundle is minimal.
  4. Serve from a CDN edge (Vercel Edge Network, Cloudflare Workers, AWS CloudFront); set Cache-Control headers on static assets: 'public, max-age=31536000, immutable'.
  5. Inline critical CSS for above-the-fold content at build time using tools like critical (npm) and load the rest with <link rel='preload' as='style'>.
  6. Use <link rel='preconnect'> and <link rel='dns-prefetch'> in <head> for every third-party origin (fonts, analytics, payment SDKs) to reduce connection setup latency.
  7. Add explicit width and height to all <img> tags or use aspect-ratio CSS to reserve layout space and eliminate CLS.
  8. Monitor real-user Core Web Vitals via web-vitals npm package reporting to your analytics or Google Search Console.
Code · Generic HTML (html)
<!-- In <head>: preconnect to critical third-party origins to reduce connection latency -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- Preload your Largest Contentful Paint image (hero/banner) so it loads earlier -->
<link rel="preload" as="image" href="/images/hero.webp" fetchpriority="high">

<!-- Hero image: eager load, explicit dimensions to prevent CLS -->
<img
  src="/images/hero.webp"
  alt="Descriptive alt text for your hero image"
  width="1200"
  height="600"
  fetchpriority="high"
  decoding="async"
>

<!-- All other images: lazy load, explicit dimensions to prevent CLS -->
<img
  src="/images/product-shot.webp"
  alt="Descriptive product alt text"
  width="800"
  height="800"
  loading="lazy"
  decoding="async"
>

<!-- Defer non-critical third-party scripts (analytics, chat, pixels) -->
<script src="https://example-analytics.com/tracker.js" defer></script>

<!-- Load non-critical CSS asynchronously to unblock render -->
<link rel="preload" href="/css/non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/non-critical.css"></noscript>

Paste these patterns into your <head> and image markup to address the most common Lighthouse failures.

Meta description too shortModerate effort

Expand your meta description to 120–160 characters so Google displays a compelling, complete snippet in search results that drives more clicks to your store.

On Headless / Custom (I build in code)

  1. Locate the <head> template partial or layout file that renders the <meta name='description'> tag.
  2. Ensure the tag value is populated from a CMS field or page-level variable, not hardcoded.
  3. Validate that the rendered HTML contains a description of 120–160 characters using 'View Source' or browser DevTools → Elements → search for 'meta name=description'.
  4. For Next.js (App Router): set the description in the generateMetadata() export for each page/layout.
  5. For Next.js (Pages Router): use the next/head component with <meta name='description' content='...' />.
  6. For Nuxt.js: use useHead() or the head property in your page component to set the description.
Code · Generic HTML (html)
<meta name="description" content="Shop the official Nike national team jerseys — advanced Dri-FIT heat tech meets bold heritage design. Free shipping on orders over $75. Explore the full collection now.">

Place this inside the <head> element of your HTML page. Replace the content value with your 120–160 character description.

Missing canonicalModerate effort

Add a self-referencing canonical tag to every page so Google knows which URL is the definitive version of that content.

On Headless / Custom (I build in code)

  1. In a Next.js project, use the `<Head>` component from `next/head` in each page component or use the `metadata` export (App Router) to add `alternates: { canonical: 'https://www.yourstore.com/your-page' }`.
  2. In a Nuxt.js project, use `useHead()` composable or the `head` property in your page component to add `{ link: [{ rel: 'canonical', href: 'https://www.yourstore.com/your-page' }] }`.
  3. For any other framework, ensure the canonical tag is rendered server-side (not only client-side via JavaScript) so crawlers see it in the initial HTML response.
  4. Build the canonical URL dynamically from the current route/path — never hardcode it — and always use the absolute URL (including https:// and domain).
  5. Verify by fetching the raw HTML of a page with `curl -s https://www.yourstore.com/your-page | grep canonical` and confirming the tag is present.
Code · Generic HTML (html)
<link rel="canonical" href="https://www.yourstore.com/your-page/" />

Paste this inside the <head> element of every page. Replace the href value with the full, absolute preferred URL of that specific page.

Missing h1Quick win

Add a single, descriptive H1 tag to every page that currently lacks one, clearly stating the page's main topic.

On Headless / Custom (I build in code)

  1. Locate the page component or template that renders the page's main content (e.g. ProductPage.jsx, CategoryPage.tsx, product.html).
  2. Ensure the primary title/name variable is wrapped in an <h1> tag — e.g. <h1>{product.name}</h1> in React/Next.js, or <h1>{{ product.title }}</h1> in a template language.
  3. Make sure no CSS rule sets display:none or visibility:hidden on the h1 element.
  4. Add a check in your CI/build pipeline or automated test suite (e.g. with axe-core or a Playwright test) to assert that every page type contains exactly one <h1>.
  5. Deploy and verify with browser DevTools.
Code · Generic HTML (html)
<h1>Women's Running Shoes</h1>

Place exactly one of these per page, as the first heading in the main content area.

Missing meta descriptionModerate effort

Write and add a unique meta description (120–160 characters) to every page that is missing one, so Google can display an accurate, compelling snippet in search results.

On Headless / Custom (I build in code)

  1. Add the tag inside the HTML `<head>`: `<meta name="description" content="Your unique description here.">` — this is the universal implementation regardless of framework.
  2. In Next.js (App Router): export a `metadata` object from your page file — set `metadata.description = 'Your description'`; Next.js renders the tag server-side.
  3. In Next.js (Pages Router): use `next/head` — `<Head><meta name='description' content='Your description' /></Head>`.
  4. In Nuxt.js: use `useHead({ meta: [{ name: 'description', content: 'Your description' }] })` in your page component, or set it in `nuxt.config.ts` as a global default.
  5. For dynamic pages (products, categories) pull the description from your CMS or API and inject it server-side to ensure it is present before the page is served to crawlers.
Code · Generic HTML (html)
<head>
  <meta charset="UTF-8">
  <meta name="description" content="Shop our full range of handmade leather wallets. Free shipping on orders over $50. Slim, durable & personalised — order yours today.">
  <title>Handmade Leather Wallets | YourStoreName</title>
</head>

Place this tag inside the <head> element of every HTML page. Keep the content between 120–160 characters.

Missing og descriptionModerate effort

Add an og:description meta tag to every page so that social media platforms display a compelling, accurate preview when your pages are shared.

On Headless / Custom (I build in code)

  1. In a Next.js app (App Router): in your page.tsx, export a generateMetadata() function that returns { openGraph: { description: 'Your description' } }.
  2. In a Next.js app (Pages Router): use the next/head component and add <meta property="og:description" content={description} /> inside <Head>.
  3. In a Nuxt.js app: use useHead() or useSeoMeta() composable with the ogDescription key.
  4. In an Astro site: use the <meta property="og:description"> tag inside your base layout's <head>, passing the value as a prop from each page's frontmatter.
  5. Ensure your CMS or data layer provides a dedicated 'og_description' or 'social_description' field per content type so editors can customise it independently of the body copy.
  6. Validate with Facebook Sharing Debugger after deployment.
Code · Generic HTML (html)
<head>
  <!-- Open Graph / Social Sharing -->
  <meta property="og:type"        content="website" />
  <meta property="og:url"         content="https://www.example.com/page-url/" />
  <meta property="og:title"       content="Page Title – Brand Name" />
  <meta property="og:description" content="A concise, compelling summary of this page in 100–160 characters that encourages clicks." />
  <meta property="og:image"       content="https://www.example.com/images/social-preview.jpg" />
</head>

Place all Open Graph tags inside the <head> element of every page.

Multiple h1Moderate effort

Remove all but one H1 tag from each page so every page has exactly one clear, descriptive primary heading.

On Headless / Custom (I build in code)

  1. Search your component library or templates for all usages of <h1> (or any component that renders an H1, e.g. <Heading level={1}>).
  2. For each page type (PDP, PLP, homepage, blog post), ensure only the single most important title component outputs an H1.
  3. In product card/grid components, ensure the product name link uses an H2 or H3, not an H1 — this is the most common source of H1 inflation in headless builds.
  4. If using a CMS to drive content, confirm the CMS rich-text renderer does not allow authors to insert H1 tags inside body content — restrict the toolbar to H2 and below.
  5. Add an automated accessibility/SEO test (e.g. with axe-core or a Playwright/Cypress assertion) that checks for exactly one H1 per page type in your CI pipeline.
Code · Generic HTML (html)
<!-- CORRECT: One H1 per page -->
<h1>Blue Running Shoes – Men's Lightweight Trainers</h1>

<!-- Major sections use H2 -->
<h2>Product Details</h2>
<p>Breathable mesh upper, responsive foam midsole...</p>

<h2>Customer Reviews</h2>
<!-- Sub-sections within a major section use H3 -->
<h3>Top Review: 5 Stars</h3>
<p>Best shoes I've ever bought!</p>

<!-- WRONG: Multiple H1 tags — do NOT do this -->
<!--
<h1>Blue Running Shoes</h1>
<h1>Product Details</h1>
<h1>Customer Reviews</h1>
-->

Correct semantic heading structure for an ecommerce page — one H1, everything else H2 or lower.

Noindex detectedQuick win

Remove or replace the noindex directive on any page you want to appear in Google search results.

On Headless / Custom (I build in code)

  1. Search your codebase (including middleware and server-side rendering functions) for any string containing 'noindex' in meta tag output or HTTP header logic (X-Robots-Tag).
  2. In Next.js: Check pages/_app.js, individual page files, and any next/head components for <meta name='robots' content='noindex'>. Also check next.config.js for headers() returning X-Robots-Tag: noindex.
  3. In Nuxt.js: Check nuxt.config.js (head.meta array) and page-level head() functions for robots noindex entries.
  4. Remove or correct the directive so the rendered HTML contains <meta name="robots" content="index,follow"> (or no robots meta tag at all, which defaults to index,follow).
  5. Deploy the fix, then verify with 'view-source:' or browser DevTools that the noindex tag is gone from the live page HTML.
  6. Submit in Google Search Console → URL Inspection → Request Indexing.
Code · Generic HTML (html)
<meta name="robots" content="index,follow">

Place inside <head>. This is the CORRECT tag that allows indexing and link-following.

Og url mismatchModerate effort

Set a unique, page-specific og:url meta tag on every page that exactly matches that page's canonical URL.

On Headless / Custom (I build in code)

  1. In your head-rendering component (e.g. Next.js: app/layout.tsx or pages/_app.tsx using next/head; Nuxt.js: nuxt.config.ts useHead or app.vue; Astro: src/layouts/Base.astro), locate where og:url is set.
  2. Replace any static/hardcoded URL with the current page's canonical URL derived from the request context — e.g. in Next.js: use `useRouter().asPath` combined with your base URL, or pass the canonical URL as a prop from getStaticProps/getServerSideProps.
  3. Ensure the value is absolute (includes https:// and domain), matches the <link rel='canonical'> output exactly, and does not include query strings or fragment identifiers unless they are part of the canonical.
  4. For static site generators (Hugo, Jekyll, Eleventy), use the built-in page URL variable (Hugo: {{ .Permalink }}, Jekyll: {{ page.url | absolute_url }}, Eleventy: {{ page.url | absoluteUrl(site.url) }}) in your base layout.
  5. Build and deploy, then use 'View Source' on multiple page types to verify uniqueness.
  6. Optionally add an automated test to your CI pipeline that checks og:url === canonical for a set of representative URLs.
Code · Generic HTML (html)
<meta property="og:url" content="https://www.example.com/exact-page-path/" />
<link rel="canonical" href="https://www.example.com/exact-page-path/" />

Place inside the <head> element. The og:url value must be the absolute canonical URL of the current page.

Page not foundModerate effort

Fix broken internal links pointing to pages that return a 404 error by either removing those links or setting up a 301 redirect from the missing URL to the correct replacement page.

On Headless / Custom (I build in code)

  1. In your server or framework configuration (e.g. Next.js next.config.js, Nuxt redirects, Nginx, Apache .htaccess), add a 301 redirect rule from the old URL to the new destination.
  2. Search your codebase (components, templates, CMS content) for any hard-coded references to the old broken URL and replace them with the correct destination URL.
  3. If using a headless CMS (Contentful, Sanity, Storyblok, etc.), update or remove the broken links directly in that CMS's content editor.
  4. Deploy the changes and verify with a curl command: `curl -I https://yourdomain.com/old-url` — you should see `HTTP/1.1 301` and a `Location:` header pointing to the new URL.
  5. Submit the updated sitemap to Google Search Console to prompt re-crawling.
Code · Generic HTML (html)
<!-- BEFORE: broken internal link -->
<a href="/old-products/discontinued-item">Shop Now</a>

<!-- AFTER: updated to correct URL -->
<a href="/products/replacement-item">Shop Now</a>

Update an internal anchor link to point to the correct URL instead of the broken one.

Redirect chainModerate effort

Update every internal link that passes through a redirect chain to point directly at the final destination URL, eliminating all intermediate hops.

On Headless / Custom (I build in code)

  1. Run a crawl of your production site (Screaming Frog, custom script using fetch/axios following redirects) to build a map of all internal URLs and their redirect chains; export the 'Final URL' for each.
  2. Search your codebase (grep or IDE search) for each intermediate URL string and replace it with the final destination URL in all href attributes, router <Link> components, and any JSON/CMS content fields.
  3. Update any redirect rules in your server config (Nginx rewrite rules, Apache .htaccess, Next.js redirects in next.config.js, Netlify _redirects, Vercel vercel.json) so that no redirect target is itself a redirect.
  4. For CMS-sourced links (Contentful, Sanity, etc.), update the relevant link fields in the CMS dashboard to point to the final URL.
  5. Deploy, then use curl -IL <url> or a crawler to verify each previously-chained URL now returns HTTP 200 in a single request (or at most one intentional redirect to HTTPS/www).
  6. Add a CI/CD check or scheduled crawl to catch new chains before they reach production.
Code · Generic HTML (html)
<!-- BEFORE: link points to an intermediate URL that itself redirects -->
<a href="https://example.com/old-page">Our Story</a>

<!-- AFTER: link points directly to the final destination URL -->
<a href="https://example.com/about-us">Our Story</a>

Replace every intermediate redirect URL in your anchor href with the final destination URL — the one that returns HTTP 200 with no further redirects.

Search results indexableModerate effort

Add a noindex robots meta tag to all internal search result pages and block the search URL path in robots.txt to prevent Google from crawling and indexing thin, duplicate search results.

On Headless / Custom (I build in code)

  1. In your search results page component or route handler, conditionally render <meta name="robots" content="noindex,follow"> in the document <head> whenever the current route matches your search path.
  2. In Next.js (App Router): in your app/search/page.tsx, export a generateMetadata function returning { robots: { index: false, follow: true } }.
  3. In Next.js (Pages Router): use next/head to inject <meta name="robots" content="noindex,follow"> inside your search page component.
  4. In Nuxt.js: use useHead({ meta: [{ name: 'robots', content: 'noindex,follow' }] }) inside your search page's setup() or definePageMeta.
  5. For robots.txt: add Disallow: /search (or your search path) to your static public/robots.txt file, or generate it dynamically from your server.
  6. Deploy and verify with Google Search Console URL Inspection.
Code · Generic HTML (html)
<meta name="robots" content="noindex,follow">

Place this tag inside the <head> element of every search results page template.

Seo category description missingModerate effort

Write a 100–200 word description for every category page explaining what products it contains and who they are for, so Google can rank it for category-level keywords.

On Headless / Custom (I build in code)

  1. Add a 'description' field (rich text or markdown) to your category/collection schema in your data source — whether that's a headless CMS (Contentful, Sanity, etc.), a database, or a custom admin.
  2. Fill in unique 100–200 word descriptions for each category.
  3. In your category page template (React, Vue, Next.js, Nuxt, etc.), fetch the description field and render it as HTML above the product grid, e.g.: <div className='category-description' dangerouslySetInnerHTML={{ __html: category.description }} />
  4. Ensure the rendered HTML is present in the server-side rendered (SSR) or statically generated (SSG) HTML output — not loaded lazily after page paint — so Google can crawl it without executing JavaScript.
  5. Apply light styling (max-width, readable font size) so the description is visually prominent.
Code · Headless / Custom (I build in code) (html)
<section class="category-description" aria-label="Category overview">
  <p>
    Our women's waterproof hiking boots are built for trail runners,
    weekend trekkers, and serious mountaineers. Every pair features a
    fully sealed waterproof membrane, aggressive rubber outsoles for grip
    on muddy terrain, and reinforced ankle support. Browse the full range
    below to find your perfect fit.
  </p>
</section>

Generic SSR/SSG output — ensure this HTML is in the server-rendered page source, not injected lazily by JavaScript.

Seo category emptyQuick win

Either populate the empty category with products, or add a noindex tag and remove it from your navigation so Google does not waste crawl budget on a page with no content.

On Headless / Custom (I build in code)

  1. Query your category/collection data source for any category where product_count == 0 at build time (SSG) or request time (SSR/ISR).
  2. Option A — Populate: Update content in your CMS or database to assign products.
  3. Option B — Noindex: In your category page component/template, conditionally render <meta name='robots' content='noindex, follow' /> in the document <head> when the product array is empty. In Next.js use the Metadata API or next/head; in Nuxt use useHead(); in Astro set it in the frontmatter head slot.
  4. Option C — Redirect: In your framework's routing layer (Next.js: getServerSideProps or next.config.js redirects; Nuxt: server middleware; SvelteKit: load function), return a 301 redirect to the parent category when the product array length is 0.
  5. Remove empty categories from any navigation data source (CMS, JSON config, API) so they are not linked from any page.
  6. After deployment, use Google Search Console URL Inspection to confirm the noindex tag is seen by Googlebot or that the 301 redirect resolves correctly.
Code · Generic HTML (html)
<meta name="robots" content="noindex, follow">

Place inside the <head> element of an empty category page to prevent Google from indexing it.

Seo category sparseModerate effort

Add more products to sparse category pages, or consolidate very thin categories into broader, related ones so every indexed category page delivers genuine value to shoppers and search engines.

On Headless / Custom (I build in code)

  1. Query your commerce backend for all categories with product counts below your threshold (e.g., fewer than 6).
  2. For categories to be enriched: update the CMS/backend data model to include a long-form description field and populate it; render it in your category template above the product grid.
  3. For categories to be merged: run a data migration to reassign product→category relationships to the surviving category, issue a 301 HTTP redirect from the old category route at the framework/CDN/server level.
  4. For categories to noindex: in your category page component's <Head> (Next.js, Nuxt, Astro, etc.), conditionally render <meta name="robots" content="noindex, follow"> when a category's product count is below your threshold or when a category is explicitly flagged in your data model.
  5. Remove noindexed or deleted category URLs from your XML sitemap and submit the updated sitemap via Google Search Console.
Code · Generic HTML (html)
<meta name="robots" content="noindex, follow">

Add inside <head> of any sparse category page you want excluded from Google's index while still being followable by crawlers.

Seo facet url not canonicalizedModerate effort

Add a canonical tag to every filtered, sorted, and paginated collection/category URL pointing back to the clean (unfiltered) category URL, so Google consolidates duplicate pages and focuses crawl budget on your real content.

On Headless / Custom (I build in code)

  1. In your page rendering logic (Next.js, Nuxt.js, Astro, etc.), detect when query parameters representing filters, sorts, or pagination are present on a category/collection route.
  2. Construct the canonical URL by stripping all recognised filter/sort/pagination parameters from the current URL, leaving only the clean path.
  3. Inject the canonical into the <head> using your framework's head-management solution (e.g. Next.js: use <Head><link rel='canonical' href={cleanCategoryUrl} /></Head> via next/head or the App Router metadata API; Nuxt: use useHead({ link: [{ rel: 'canonical', href: cleanCategoryUrl }] })).
  4. Ensure this logic runs server-side (SSR or SSG) so the canonical is present in the initial HTML response, not injected after JavaScript runs.
  5. Add an automated test that checks the canonical tag value on a URL with query parameters matches the expected clean URL.
  6. Deploy and verify with Google Search Console URL Inspection.
Code · Generic HTML (html)
<head>
  <!-- All other head tags -->
  <link rel="canonical" href="https://www.example.com/collections/hoops">
</head>

Place this inside the <head> of every filtered/sorted/paginated category page. The href must be the full, absolute clean URL (no query parameters).

Seo product missing gtinModerate effort

Add a GTIN (and/or MPN) field to your Product structured data so Google can match your listings to its product knowledge graph for rich results and Google Shopping.

On Headless / Custom (I build in code)

  1. Retrieve the GTIN and MPN values from your product data source (PIM, ERP, or database).
  2. In your product page rendering logic (React, Vue, Next.js, Nuxt, etc.), construct a JSON-LD object of type 'Product' that includes 'gtin' (or the specific sub-type) and 'mpn' properties.
  3. Inject the JSON-LD as a <script type='application/ld+json'> tag in the <head> of each product page, populated server-side or at build time.
  4. Validate with Google's Rich Results Test and confirm 'gtin' and 'mpn' appear correctly in the parsed schema.
Seo product price not in htmlModerate effort

Render your product price in the server-generated HTML so Google and other crawlers can read it directly — without executing JavaScript.

On Headless / Custom (I build in code)

  1. If using Next.js: move price fetching into getServerSideProps (Pages Router) or a Server Component (App Router) so the price is serialised into the initial HTML payload.
  2. If using Nuxt.js: use asyncData or useFetch in a server context so the price is part of the SSR output.
  3. If using Astro: fetch price data at build time or request time in the frontmatter (---) section, then output it in the template markup — Astro ships zero JS by default so this is naturally static.
  4. In all cases, also inject a JSON-LD <script type='application/ld+json'> Product schema block into <head> during server rendering with the 'offers' → 'price' field populated.
  5. Verify with: curl https://yourstore.com/product-url | grep '<price-string>' to confirm the value is in the raw HTTP response.
Seo variant urls in sitemapModerate effort

Remove product variant URLs (e.g. ?variant= or ?sku= query strings) from your XML sitemap and keep only the canonical base product URL for each product.

On Headless / Custom (I build in code)

  1. In your sitemap generation script or build pipeline, fetch the list of all product URLs from your CMS or commerce API.
  2. Before writing each <loc> entry, strip any query string: in Node.js use `new URL(url).pathname`; in Python use `urllib.parse.urlsplit(url).path`.
  3. Write only the clean, canonical path (e.g. /products/blue-sneaker) to the <loc> element.
  4. Ensure each corresponding product page also outputs a <link rel='canonical'> tag pointing to the same clean URL.
  5. Rebuild and deploy the sitemap, then re-submit in Google Search Console.
Code · Headless / Custom (I build in code) (javascript)
// Node.js — strip query parameters from product URLs before writing sitemap
const { SitemapStream, streamToPromise } = require('sitemap');
const { createWriteStream } = require('fs');
const { Readable } = require('stream');

// Example: product URLs fetched from your CMS or commerce API
const rawProductUrls = [
  'https://www.example.com/products/blue-sneaker',
  'https://www.example.com/products/blue-sneaker?variant=12345',  // should be removed
  'https://www.example.com/products/red-hoodie',
  'https://www.example.com/products/red-hoodie?sku=red-xl',       // should be removed
];

// Strip query strings and deduplicate
const canonicalUrls = [
  ...new Set(
    rawProductUrls.map(url => {
      const parsed = new URL(url);
      return parsed.origin + parsed.pathname; // drops search params
    })
  )
];

// Build sitemap entries
const links = canonicalUrls.map(url => ({
  url,
  changefreq: 'weekly',
  priority: 0.8,
}));

// Write sitemap.xml
async function generateSitemap() {
  const stream = new SitemapStream({ hostname: 'https://www.example.com' });
  const pipeline = Readable.from(links).pipe(stream);
  const data = await streamToPromise(pipeline);
  require('fs').writeFileSync('./public/sitemap.xml', data.toString());
  console.log('Sitemap written with', links.length, 'canonical URLs');
}

generateSitemap();

Node.js sitemap generation snippet — strips query parameters before writing <loc> entries.

Slow pageModerate effort

Reduce your page load time to under 3 seconds by compressing images, eliminating render-blocking scripts and styles, and improving server response speed.

On Headless / Custom (I build in code)

  1. Run Lighthouse CI in your deployment pipeline to catch performance regressions automatically on every deploy.
  2. Use Next.js Image component (<Image>) or equivalent framework primitives to get automatic WebP conversion, responsive srcset, and lazy loading out of the box.
  3. Code-split aggressively: use dynamic imports for heavy components not needed on initial load, and tree-shake unused library code.
  4. Serve all static assets through a CDN (Cloudflare, Fastly, AWS CloudFront) and set long-lived cache headers (Cache-Control: max-age=31536000, immutable) on versioned assets.
  5. Enable Brotli compression at the edge/server layer and use HTTP/2 or HTTP/3.
  6. Profile real-user performance with Chrome DevTools Performance tab and address the largest LCP element first, then Total Blocking Time (TBT), then Cumulative Layout Shift (CLS).
Code · Generic HTML (html)
<img
  src="product-image.webp"
  alt="Descriptive product alt text"
  width="800"
  height="600"
  loading="lazy"
  decoding="async"
>

Add these attributes to every image below the fold to enable native lazy loading and prevent layout shift.

Answer Engine Optimization · 5 fixes

Aeo schema client renderedModerate effort

Move your JSON-LD structured data from client-side JavaScript injection to server-rendered HTML so that AI crawlers and search engines can read it in the raw page source.

On Headless / Custom (I build in code)

  1. Ensure your JSON-LD block is rendered by the server framework (Next.js, Nuxt.js, Astro, etc.) during the server-side render (SSR) or static generation (SSG) pass — not in a useEffect, onMounted, or componentDidMount hook, which all run only in the browser.
  2. In Next.js: use the <Head> component from 'next/head' or the metadata API (App Router) inside your page component, and populate schema values from getServerSideProps or getStaticProps so the data is available at render time.
  3. In Nuxt.js: use useHead() in your page component and fetch product data server-side via useFetch() or useAsyncData() so schema values are populated during SSR.
  4. In Astro: place the <script type="application/ld+json"> block directly in the frontmatter/template section of your .astro file — Astro renders this statically by default.
  5. Confirm by running 'curl -s https://yoursite.com/your-page | grep application/ld+json' — the schema block must appear in the curl output (raw HTML), not only in a browser.
Code · Generic HTML (html)
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Your Product Name",
  "description": "A clear description of your product.",
  "sku": "YOUR-SKU-123",
  "brand": {
    "@type": "Brand",
    "name": "Your Brand Name"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://www.example.com/products/your-product",
    "priceCurrency": "USD",
    "price": "49.99",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.7",
    "reviewCount": "128"
  }
}
</script>

Place this block inside <head> or <body> in the server-rendered HTML. All values must be populated by the server, not by JavaScript.

Missing schema breadcrumblistModerate effort

Add BreadcrumbList structured data (JSON-LD) to every page so Google can display your site's navigation path directly in search results.

On Headless / Custom (I build in code)

  1. Create a reusable BreadcrumbJsonLd component that accepts an array of {position, name, url} objects as props.
  2. Render a <script type='application/ld+json'> tag containing the serialised BreadcrumbList JSON-LD object inside the <head> of every page that has a breadcrumb trail.
  3. In Next.js, place the component inside the <Head> component from next/head or use the App Router's generateMetadata alongside a direct script tag. In Nuxt.js, use useHead() composable.
  4. Build the breadcrumb array from your routing/data layer (e.g. category tree, URL segments) so it is generated dynamically at build or request time.
  5. Validate with Google's Rich Results Test.
Code · Generic HTML (json)
<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": "Women's Clothing",
      "item": "https://www.example.com/womens-clothing/"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Dresses",
      "item": "https://www.example.com/womens-clothing/dresses/"
    },
    {
      "@type": "ListItem",
      "position": 4,
      "name": "Blue Wrap Dress",
      "item": "https://www.example.com/womens-clothing/dresses/blue-wrap-dress/"
    }
  ]
}
</script>

Place this <script> block inside <head> or anywhere in <body> on any page with a breadcrumb trail. Extend itemListElement with one ListItem per level.

Missing schema organizationQuick win

Add an Organization schema markup block to your store's homepage (and/or sitewide template) so search engines and AI assistants can definitively identify your brand.

On Headless / Custom (I build in code)

  1. In your root layout component (e.g., _app.tsx / layout.tsx in Next.js, app.vue in Nuxt, BaseLayout.astro in Astro), import or use your framework's <Head> component.
  2. Render a <script type='application/ld+json' dangerouslySetInnerHTML={{ __html: JSON.stringify(orgSchema) }} /> (React/Next.js) or the equivalent for your framework, passing your Organization object.
  3. Define the orgSchema object as a JavaScript/TypeScript constant with all required fields.
  4. Ensure it renders server-side (SSR or SSG) so crawlers receive it in the initial HTML response.
  5. Verify using the Rich Results Test.
Code · Generic HTML (html)
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "Acme Store",
  "url": "https://www.acmestore.com",
  "logo": {
    "@type": "ImageObject",
    "url": "https://www.acmestore.com/images/logo.png",
    "width": 250,
    "height": 250
  },
  "sameAs": [
    "https://www.facebook.com/acmestore",
    "https://www.instagram.com/acmestore",
    "https://twitter.com/acmestore",
    "https://www.linkedin.com/company/acmestore",
    "https://www.youtube.com/c/acmestore"
  ],
  "contactPoint": {
    "@type": "ContactPoint",
    "contactType": "customer service",
    "email": "hello@acmestore.com"
  }
}
</script>

Drop this complete block inside your <head>…</head> section on every page. Replace all placeholder values.

Missing schema productModerate effort

Add Product schema markup (JSON-LD) to every product page so search engines can display rich results — including price, availability, and ratings — directly in search.

On Headless / Custom (I build in code)

  1. In your product page component (React, Vue, Svelte, etc.), construct the schema object dynamically from your product data source (API, CMS, or database).
  2. Render it as a <script type='application/ld+json'> tag inside the page <head> using your framework's head management utility (e.g. Next.js next/head, Nuxt.js useHead, or a standard dangerouslySetInnerHTML approach in React).
  3. Ensure the schema renders in the server-side HTML (SSR or SSG) so it is present in the initial page response — do not rely on client-side-only injection.
  4. Validate with Google's Rich Results Test.
Non iso dateQuick win

Change every datePublished (and dateModified) value in your structured data from a human-readable string to a strict ISO-8601 date — formatted as YYYY-MM-DD or YYYY-MM-DDThh:mm:ss+hh:mm — so search engines can reliably parse your content's freshness.

On Headless / Custom (I build in code)

  1. Find every location in your codebase where `datePublished` or `dateModified` is set in a JSON-LD object.
  2. Replace any date construction that outputs a non-ISO string (e.g., new Date().toString(), toDateString(), toLocaleDateString()) with toISOString() in JavaScript/TypeScript — e.g., new Date(publishedAt).toISOString() returns '2025-03-12T09:00:00.000Z'.
  3. In Python, use datetime.isoformat() or datetime.strftime('%Y-%m-%dT%H:%M:%S+00:00').
  4. In PHP, use date('c', $timestamp) or (new DateTime($dateString))->format(DateTime::ATOM).
  5. Ensure the value is a string in the final JSON-LD output, not a Date object.
  6. Deploy, then validate with Google's Rich Results Test or by fetching the page source and inspecting the JSON-LD block.
Code · Headless / Custom (I build in code) (json)
// JavaScript / TypeScript example
const publishedAt = new Date('Wed Mar 12 2025'); // your raw date source

const schema = {
  "@context": "https://schema.org",
  "@type": "WebPage",
  "name": "Your Page Title",
  "url": "https://www.example.com/your-page/",
  "datePublished": publishedAt.toISOString(),          // "2025-03-12T00:00:00.000Z"
  "dateModified":  new Date().toISOString()             // today's date at current time
};

// Inject into the page <head>
const script = document.createElement('script');
script.type = 'application/ld+json';
script.textContent = JSON.stringify(schema);
document.head.appendChild(script);

JavaScript/TypeScript snippet — use toISOString() to produce a valid ISO-8601 string from any Date object.

Accessibility (WCAG) · 34 fixes

Aria allowed roleQuick win

Remove or replace any ARIA `role` attribute that is not permitted on the HTML element it is applied to — in particular, do not add `role="textbox"` (or other interactive roles) to elements that already have a native semantic role which conflicts with it.

On Headless / Custom (I build in code)

  1. Search your component/template source for the invalid `role` attribute value (e.g. `role="textbox"`).
  2. Determine whether the fix is: (a) deleting the `role` attribute, (b) replacing it with a role permitted by the ARIA-in-HTML spec for that element, or (c) replacing the element with the correct native HTML tag.
  3. Prefer semantic HTML over ARIA — use `<input type="text">` instead of `<div role="textbox">` wherever possible.
  4. After applying the fix, run `axe-core` in your CI pipeline or use axe DevTools in the browser to confirm the violation is resolved.
Code · Generic HTML (html)
<!-- ❌ WRONG: role="textbox" is not permitted on <select> -->
<select role="textbox" name="country">
  <option value="us">United States</option>
</select>

<!-- ✅ FIX A: Remove the invalid role — <select> already has the correct native semantics -->
<select name="country">
  <option value="us">United States</option>
</select>


<!-- ❌ WRONG: role="textbox" on a <div> with no keyboard/input handling -->
<div role="textbox" class="fake-input">Enter your name</div>

<!-- ✅ FIX B: Replace with a real <input> element -->
<label for="name">Your name</label>
<input type="text" id="name" name="name" placeholder="Enter your name">


<!-- ❌ WRONG: role="textbox" on a <p> element -->
<p role="textbox">Some editable text</p>

<!-- ✅ FIX C: If you need an editable region, use contenteditable + the permitted role -->
<!-- role="textbox" IS permitted on elements with contenteditable, like a <div> -->
<div role="textbox"
     contenteditable="true"
     aria-multiline="false"
     aria-label="Your name">
</div>

Common patterns: wrong role on the left, correct fix on the right (as comments).

Aria hidden focusModerate effort

Remove `aria-hidden="true"` from any element that contains focusable children (links, buttons, inputs), or remove those focusable children from the keyboard tab order so they cannot receive focus while hidden from assistive technology.

On Headless / Custom (I build in code)

  1. Search your codebase for all occurrences of aria-hidden (e.g., grep -r 'aria-hidden' ./src).
  2. For each aria-hidden='true' container, check whether it contains focusable elements: <a href>, <button>, <input>, <select>, <textarea>, or elements with tabindex >= 0.
  3. Apply the correct fix: when setting aria-hidden='true' on a container, also loop through all focusable descendants and set tabindex='-1' on each. When removing aria-hidden (revealing the element), also restore their original tabindex values (or remove tabindex='-1').
  4. Encapsulate this logic in a reusable utility function (e.g., setAriaHidden(element, isHidden)) that always handles both attributes together — never set one without the other.
  5. Write automated tests (e.g., with jest-axe or Playwright + axe-core) to catch regressions in CI/CD before they reach production.
  6. Manually test with a screen reader (NVDA + Firefox, VoiceOver + Safari) to confirm that tabbing through the page does not land focus in any aria-hidden region.
Aria input field nameModerate effort

Add a descriptive accessible name (aria-label, aria-labelledby, or a visible <label>) to every ARIA input field so assistive technologies can identify it.

On Headless / Custom (I build in code)

  1. Audit all <input>, <select>, <textarea>, and elements with role='textbox'/'searchbox'/'spinbutton'/'slider'/'combobox' in your component library.
  2. For each, ensure one of the following is true: (a) a <label htmlFor='inputId'> exists with matching id on the input, (b) aria-label='...' is set directly on the element, or (c) aria-labelledby='someId' points to an existing element with descriptive text.
  3. In React/Next.js/Nuxt, build a reusable <FormField> component that always renders a <label> and throws a prop-types or TypeScript error if the label prop is missing.
  4. Add automated accessibility checks to your CI pipeline using jest-axe or Playwright with axe-core so unlabeled inputs are caught before deployment.
  5. Verify in the browser with axe DevTools and by navigating the form using only VoiceOver (macOS) or NVDA (Windows).
Code · Headless / Custom (I build in code) (html)
// AccessibleInput.tsx
import React from 'react';

interface AccessibleInputProps {
  id: string;
  label: string;
  hideLabel?: boolean;
  type?: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  placeholder?: string;
}

const srOnlyStyle: React.CSSProperties = {
  position: 'absolute',
  width: '1px',
  height: '1px',
  padding: 0,
  margin: '-1px',
  overflow: 'hidden',
  clip: 'rect(0,0,0,0)',
  whiteSpace: 'nowrap',
  border: 0,
};

export function AccessibleInput({
  id,
  label,
  hideLabel = false,
  type = 'text',
  value,
  onChange,
  placeholder,
}: AccessibleInputProps) {
  return (
    <div>
      <label htmlFor={id} style={hideLabel ? srOnlyStyle : undefined}>
        {label}
      </label>
      <input
        id={id}
        type={type}
        value={value}
        onChange={onChange}
        placeholder={placeholder}
        // aria-label is redundant when a <label for> is present,
        // but included as a belt-and-suspenders fallback:
        aria-label={hideLabel ? label : undefined}
      />
    </div>
  );
}

React/Next.js reusable accessible form field component.

Aria required attrModerate effort

Add all required ARIA attributes (such as aria-expanded and aria-controls) to every interactive element that uses an ARIA role, so assistive technologies can communicate widget state to users.

On Headless / Custom (I build in code)

  1. Find the component that renders the interactive widget (e.g. a Dropdown, Accordion, Combobox, or Tab component).
  2. Add aria-expanded={isOpen} (boolean → React converts to 'true'/'false') and aria-controls='target-element-id' to the trigger element's JSX/HTML.
  3. Ensure the controlled element has the matching id attribute.
  4. Wire the isOpen state variable (or equivalent) so it updates correctly on user interaction.
  5. Test with axe-core (npm package) in your CI pipeline or use the axe DevTools browser extension manually.
Code · Headless / Custom (I build in code) (javascript)
import { useState } from 'react';

function AccordionItem({ id, title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const panelId = `panel-${id}`;
  const triggerId = `trigger-${id}`;

  return (
    <div className="accordion-item">
      <button
        id={triggerId}
        aria-expanded={isOpen}
        aria-controls={panelId}
        onClick={() => setIsOpen(!isOpen)}
        className="accordion-trigger"
      >
        {title}
      </button>
      <div
        id={panelId}
        role="region"
        aria-labelledby={triggerId}
        hidden={!isOpen}
        className="accordion-panel"
      >
        {children}
      </div>
    </div>
  );
}

export default AccordionItem;

React functional component example — accordion item with correct aria-expanded and aria-controls wiring.

Aria required childrenModerate effort

Add the required child ARIA roles (e.g. `role="option"` inside `role="listbox"`, or `role="group"`/`role="option"` inside `role="combobox"`) so every composite ARIA widget contains the child roles the ARIA specification demands.

On Headless / Custom (I build in code)

  1. Search your component library for any element that sets a composite container ARIA role (role="listbox", role="combobox", role="menu", role="tablist", role="radiogroup", role="tree", role="grid") either as a static attribute or via JavaScript.
  2. Cross-reference each container role with the required owned roles table in the WAI-ARIA specification and add the missing child roles to the appropriate descendant elements.
  3. Prefer established accessible component libraries (Radix UI, Headless UI, Reach UI, Adobe React Aria) which implement the full ARIA role trees correctly out of the box, rather than building composite widgets from scratch.
  4. For server-side rendered HTML, add role attributes directly in your template/JSX. For client-side rendered children, set the roles as soon as child elements are inserted into the DOM (not after a setTimeout).
  5. Add automated accessibility tests (jest-axe, Playwright + axe-core) to your CI pipeline to catch regressions.
  6. Manually test the complete widget with NVDA+Firefox and VoiceOver+Safari.
Aria required parentModerate effort

Wrap every ARIA role that requires a parent container (e.g. `menuitem`, `option`, `listitem`) inside the correct parent element carrying the matching ARIA role (e.g. `menu`/`menubar`, `listbox`, `list`).

On Headless / Custom (I build in code)

  1. Search your component codebase for the element carrying the orphaned child ARIA role (e.g. `role='menuitem'`, `role='option'`).
  2. Identify its required parent role from the ARIA spec and add a wrapping element with that role directly enclosing all sibling child-role elements in your component's JSX, HTML template, or rendering function.
  3. Where possible, use native semantic HTML elements that carry implicit ARIA roles (e.g. `<ul>`/`<li>` for lists) instead of adding roles to generic divs.
  4. Run automated tests with axe-core (e.g. via jest-axe or Cypress-axe) as part of your CI pipeline to catch regressions, then verify manually with a screen reader.
Code · Generic HTML (html)
<!-- INCORRECT: menuitem with no required parent -->
<nav>
  <a href="/shop" role="menuitem">Shop</a>
  <a href="/about" role="menuitem">About</a>
  <a href="/contact" role="menuitem">Contact</a>
</nav>

<!-- CORRECT: menuitem children wrapped in role="menubar" -->
<nav>
  <ul role="menubar">
    <li role="none"><a href="/shop" role="menuitem">Shop</a></li>
    <li role="none"><a href="/about" role="menuitem">About</a></li>
    <li role="none"><a href="/contact" role="menuitem">Contact</a></li>
  </ul>
</nav>

<!-- CORRECT: option children wrapped in role="listbox" -->
<div role="listbox" aria-label="Select a size">
  <div role="option" aria-selected="false">Small</div>
  <div role="option" aria-selected="true">Medium</div>
  <div role="option" aria-selected="false">Large</div>
</div>

<!-- CORRECT: tab children wrapped in role="tablist" -->
<div role="tablist" aria-label="Product details">
  <button role="tab" aria-selected="true" aria-controls="panel-desc">Description</button>
  <button role="tab" aria-selected="false" aria-controls="panel-reviews">Reviews</button>
</div>
<div id="panel-desc" role="tabpanel">Description content here.</div>
<div id="panel-reviews" role="tabpanel" hidden>Reviews content here.</div>

Correct pattern: child roles (menuitem) wrapped in the required parent role (menu/menubar). Apply the same principle to other role pairs.

Aria valid attrQuick win

Find and replace every misspelled or non-existent ARIA attribute name (e.g. `aria-labeledby`) with the correct, spec-defined name (e.g. `aria-labelledby`) across your entire site's HTML and templates.

On Headless / Custom (I build in code)

  1. Run a global find-and-replace in your repository for the misspelled attribute string (e.g. `aria-labeledby`) using your IDE or the command line: `grep -r 'aria-labeledby' ./src`
  2. Replace every occurrence with the correctly spelled attribute (e.g. `aria-labelledby`), including inside JSX (`aria-labelledby={...}`), Vue templates, Handlebars, Nunjucks, or any other templating language in use.
  3. Check generated or dynamic HTML produced by JavaScript — search component files for the attribute being set via `element.setAttribute('aria-labeledby', ...)` and correct the string argument.
  4. Commit the changes, run your build, and deploy.
  5. Add a linting rule to catch future regressions: eslint-plugin-jsx-a11y (React) or axe-linter will flag invalid ARIA attribute names at development time.
  6. Verify the fix in the browser using the axe DevTools extension or by running `axe(document)` in the browser console.
Code · Headless / Custom (I build in code) (html)
// WRONG: aria-labeledby is not a recognised ARIA attribute — React will
// pass it through to the DOM, but assistive technologies will ignore it.
// function SiteNav() {
//   return (
//     <nav aria-labeledby="nav-heading">
//       <h2 id="nav-heading">Main navigation</h2>
//       ...
//     </nav>
//   );
// }

// CORRECT: aria-labelledby (double 'l')
function SiteNav() {
  return (
    <nav aria-labelledby="nav-heading">
      <h2 id="nav-heading" className="visually-hidden">Main navigation</h2>
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/shop">Shop</a></li>
        <li><a href="/contact">Contact</a></li>
      </ul>
    </nav>
  );
}

React JSX example correcting a misspelled ARIA attribute on a navigation landmark. The same principle applies to Vue, Angular, Svelte, or plain JS.

Avoid inline spacingModerate effort

Remove hard-coded text-spacing CSS values from inline `style` attributes so users can override them with their own stylesheets or browser settings.

On Headless / Custom (I build in code)

  1. Search your component files and templates for `style=` props/attributes containing `lineHeight`, `letterSpacing`, `wordSpacing`, or spacing-related `margin`/`padding`.
  2. Move those values into a CSS/SCSS class, a CSS custom property, or a design-token in your stylesheet (or Tailwind utility class if using a utility framework).
  3. In React/Vue/Svelte etc., replace `style={{ lineHeight: '1.6' }}` with `className="product-body"` and define `.product-body { line-height: 1.6; }` in your stylesheet.
  4. For CSS-in-JS (styled-components, Emotion), ensure spacing is applied via the component's static styles, not dynamically injected `style` tags that behave like inline overrides.
  5. Run the axe-core library in your test suite (`@axe-core/react` or similar) to catch regressions automatically in CI.
  6. Manually test by bookmarking the WCAG 1.4.12 text-spacing test bookmarklet and verifying no content is clipped or overlaps after applying it.
Code · Headless / Custom (I build in code) (css)
/* styles/typography.css or equivalent CSS module */
.text-spaced {
  line-height: 1.6;
  letter-spacing: 0.05em;
  word-spacing: 0.1em;
  /* Do NOT set these as inline style props in JSX/TSX/Vue templates */
}

Generic CSS class — import into your stylesheet or CSS module. Reference it instead of an inline style prop.

Color contrastQuick win

Increase the contrast ratio between your text color and its background color to meet the WCAG 4.5:1 minimum, so all users — including those with low vision — can read your content clearly.

On Headless / Custom (I build in code)

  1. Use browser DevTools to find the CSS class or variable controlling the failing text color.
  2. Open your design-token or variables file (e.g. _variables.scss, tokens.css, tailwind.config.js) and update the color value to one that passes 4.5:1.
  3. If using a utility-class framework like Tailwind, swap the class (e.g. replace 'text-gray-500' with 'text-gray-700') — verify the new class passes contrast.
  4. Commit, build, and deploy. Re-run your accessibility scanner to confirm.
Code · Generic HTML (css)
/* BEFORE (fails WCAG 1.4.3 — ratio 4.47:1 on white) */
.secondary-text {
  color: #777777;
}

/* AFTER (passes WCAG 1.4.3 — ratio ~7.0:1 on white) */
.secondary-text {
  color: #595959;
}

Replace the failing color with one that achieves 4.5:1+ contrast on white (#ffffff). #595959 achieves ~7.0:1 on white.

Definition listModerate effort

Ensure every `<dl>` (definition list) element contains only properly nested `<dt>` and `<dd>` children, with no stray text, divs, or other elements placed directly inside it.

On Headless / Custom (I build in code)

  1. Search your component library/source files for `<dl` (or `dl` JSX elements in React/Vue/Svelte).
  2. Audit each component's render output: direct children of `<dl>` must be only `<dt>`, `<dd>`, or `<div>` elements.
  3. Fix the component source, run your build, and verify with `axe-core` in your test suite (e.g. jest-axe) and the axe browser extension in the browser.
Code · Generic HTML (html)
<!-- ✅ CORRECT: only <dt> and <dd> as direct children -->
<dl>
  <dt>Material</dt>
  <dd>100% Organic Cotton</dd>

  <dt>Weight</dt>
  <dd>180 GSM</dd>

  <dt>Care Instructions</dt>
  <dd>Machine wash cold</dd>
  <dd>Tumble dry low</dd>
</dl>

<!-- ✅ ALSO CORRECT: <div> wrappers for layout/styling -->
<dl>
  <div>
    <dt>Material</dt>
    <dd>100% Organic Cotton</dd>
  </div>
  <div>
    <dt>Weight</dt>
    <dd>180 GSM</dd>
  </div>
</dl>

<!-- ❌ WRONG: stray <p>, <span>, or plain text directly inside <dl> -->
<!-- <dl>
  <p>Product Details</p>
  <dt>Material</dt>
  <dd>Cotton</dd>
</dl> -->

Correct `<dl>` structure — use this pattern for all product specs, FAQs, and paired term/value content.

Document titleQuick win

Add a descriptive, non-empty `<title>` element to every page so browsers, screen readers, and search engines can identify it.

On Headless / Custom (I build in code)

  1. In your HTML template or layout component, ensure every page renders a `<title>` tag inside `<head>` with a non-empty, page-specific string.
  2. For React/Next.js: use the `<Head>` component from `next/head` or the `metadata` export in the App Router to set `title` per page.
  3. For Nuxt.js: use `useHead({ title: 'Page Name – Store Name' })` in each page component or set a global `titleTemplate` in `nuxt.config.ts`.
  4. For any framework: implement a global title template (e.g. `%s – Store Name`) and override it per page with the specific page name.
  5. Add a fallback in your root layout so that if a page-level title is missing, the store name is used rather than an empty string.
  6. Run an automated accessibility test (axe, Lighthouse) in CI to catch any page missing a title before it reaches production.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Women's Running Shoes – Acme Sports</title>
</head>
<body>
  <!-- page content -->
</body>
</html>

Place this inside the <head> section of every HTML page. Replace the text with the actual page and store name.

Focus not visibleQuick win

Add a clearly visible focus indicator to every interactive element so keyboard users can always see which element is currently selected.

On Headless / Custom (I build in code)

  1. Search your entire CSS/SCSS/styled-components codebase for `outline: none` or `outline: 0` using your IDE's global search.
  2. Remove or scope those rules to only apply where absolutely necessary (e.g. when you are providing a fully custom focus style for that element).
  3. Add the universal :focus / :focus-visible rule in your global stylesheet so it applies site-wide.
  4. Ensure your design system's component library (button, input, link, modal trigger) all expose a visible focus state.
  5. Run axe DevTools or Lighthouse in CI to enforce no regression.
Code · Generic HTML (css)
/* =====================================================
   Universal visible focus indicator — WCAG 2.4.7
   Add to your global/main stylesheet.
   Adjust the color values to match your brand palette
   while keeping contrast ≥ 3:1 against the background.
   ===================================================== */

/* Modern browsers: :focus-visible only shows the ring
   for keyboard navigation, not mouse clicks.           */
*:focus-visible {
  outline: 3px solid #005FCC;   /* high-contrast blue  */
  outline-offset: 2px;          /* gap between element and ring */
  border-radius: 2px;           /* optional: soften corners */
}

/* Fallback for older browsers that don't support
   :focus-visible — applies to all focus events.       */
*:focus {
  outline: 3px solid #005FCC;
  outline-offset: 2px;
}

/* If you must remove the outline for mouse users in
   older browsers, do it ONLY when :focus-visible is
   NOT matched — never remove it globally.             */
*:focus:not(:focus-visible) {
  outline: none;
}

Add to your main global stylesheet. This is the safest, most universal fix.

Frame titleQuick win

Add a descriptive `title` attribute to every `<iframe>` and `<frame>` element on your store so screen-reader users know what each embedded frame contains before entering it.

On Headless / Custom (I build in code)

  1. Search your entire codebase for '<iframe' and 'Frame' component usages.
  2. Add a title prop/attribute to every instance with a meaningful description of the frame's content.
  3. Create a lint rule or accessibility test (e.g. eslint-plugin-jsx-a11y rule 'iframe-has-title' for React, or an axe-core unit test) to prevent future regressions.
  4. For iframes from third-party SDKs you cannot control, use JavaScript after mount to set the title attribute, or file a bug with the third-party vendor.
Code · Headless / Custom (I build in code) (javascript)
// AccessibleIframe.jsx
// Usage: <AccessibleIframe src="https://..." title="Google Map of our store" />
import PropTypes from 'prop-types';

export default function AccessibleIframe({ src, title, width = '100%', height = 400, ...rest }) {
  if (!title) {
    throw new Error('AccessibleIframe requires a non-empty title prop (WCAG 4.1.2 / frame-title rule).');
  }
  return (
    <iframe
      src={src}
      title={title}
      width={width}
      height={height}
      loading="lazy"
      style={{ border: 0 }}
      {...rest}
    />
  );
}

AccessibleIframe.propTypes = {
  src:    PropTypes.string.isRequired,
  title:  PropTypes.string.isRequired,
  width:  PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};

React component example — enforce the title prop at the component level.

Heading orderModerate effort

Restructure your page headings so they follow a logical, sequential order (H1 → H2 → H3) with no levels skipped.

On Headless / Custom (I build in code)

  1. Run the axe-core library or the axe DevTools CLI against your pages to get a full list of heading-order violations with exact DOM locations.
  2. In your component library, audit every component that renders a heading (e.g., ProductTitle, SectionHeader, CardTitle) — these often hard-code a heading level (e.g., always <h3>) regardless of context.
  3. Refactor heading components to accept a 'level' prop (e.g., level={2}) that maps to the correct HTML tag dynamically, e.g. in React: const Tag = `h${level}`; return <Tag className={styles.heading}>{children}</Tag>;
  4. Update each page template to pass the correct level based on the component's position in the page hierarchy — the page-level H1 is typically the product or page name; section titles are H2; subsections are H3.
  5. Add an automated axe-core test (e.g., via jest-axe or Cypress-axe) to your CI pipeline to prevent regressions.
Code · Headless / Custom (I build in code) (html)
// DynamicHeading.jsx — a reusable heading component that renders
// the correct H1–H6 tag based on where it is used in the page hierarchy.

import React from 'react';

/**
 * Usage:
 *   <DynamicHeading level={1} className="page-title">Women's Shoes</DynamicHeading>
 *   <DynamicHeading level={2} className="section-title">Best Sellers</DynamicHeading>
 *   <DynamicHeading level={3} className="product-card__title">UltraRun Pro 5</DynamicHeading>
 */
export function DynamicHeading({ level = 2, className, children }) {
  const validLevel = Math.min(Math.max(Math.round(level), 1), 6);
  const Tag = `h${validLevel}`;
  return <Tag className={className}>{children}</Tag>;
}

// Example page usage — heading levels match the page outline:
//
// function ProductArchivePage() {
//   return (
//     <main>
//       <DynamicHeading level={1} className="page-title">
//         {category.name}
//       </DynamicHeading>
//
//       <section>
//         <DynamicHeading level={2} className="section-title">
//           Featured Products
//         </DynamicHeading>
//
//         {products.map(p => (
//           <article key={p.id}>
//             <DynamicHeading level={3} className="product-card__title">
//               {p.name}
//             </DynamicHeading>
//           </article>
//         ))}
//       </section>
//     </main>
//   );
// }

React component pattern: accept a 'level' prop so the heading tag is flexible and always matches the page hierarchy.

Html has langQuick win

Add a valid `lang` attribute to the `<html>` element so browsers, screen readers, and search engines know what language your page is written in.

On Headless / Custom (I build in code)

  1. Locate the file that renders the root HTML document — typically index.html, _document.js, app.html, layout.html, or your framework's equivalent.
  2. Find the `<html>` tag and add a `lang` attribute with the correct BCP 47 language code, e.g., `<html lang="en">`.
  3. If you serve multiple languages, set the lang attribute dynamically based on the current locale/route.
  4. Remove any `xml:lang` attribute if present (it is not valid in HTML5).
  5. Deploy and verify via View Source or browser DevTools.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Your Page Title</title>
</head>
<body>
  <!-- page content -->
</body>
</html>

Add the lang attribute to the opening <html> tag of every HTML page. Use a valid BCP 47 language tag.

Image altModerate effort

Add descriptive alt text to every meaningful image, and mark purely decorative images with an empty alt attribute or role="presentation".

On Headless / Custom (I build in code)

  1. Add an alt attribute to every <img> tag in your HTML/JSX/template code. Meaningful images get a descriptive string; decorative images get alt="".
  2. In React/JSX: <img src={product.imageUrl} alt={product.imageAlt || product.name} />
  3. Enforce this at the component level by making alt a required prop and using PropTypes or TypeScript to catch missing values at build time.
  4. For images fetched from a headless CMS or PIM, ensure the API response includes an alt text field and that your frontend always renders it — never hard-code empty or omit the attribute.
  5. Add an ESLint rule (eslint-plugin-jsx-a11y: 'jsx-a11y/alt-text': 'error') to your CI pipeline so missing alt text causes a build failure.
Code · Headless / Custom (I build in code) (html)
// TypeScript + React — reusable accessible image component
import React from 'react';

interface AccessibleImageProps {
  src: string;
  alt: string;          // Required — pass empty string "" for decorative images
  width?: number;
  height?: number;
  className?: string;
  decorative?: boolean; // Set true to output alt="" + role="presentation"
}

export const AccessibleImage: React.FC<AccessibleImageProps> = ({
  src,
  alt,
  width,
  height,
  className,
  decorative = false,
}) => {
  if (decorative) {
    return (
      <img
        src={src}
        alt=""
        role="presentation"
        width={width}
        height={height}
        className={className}
        loading="lazy"
      />
    );
  }
  return (
    <img
      src={src}
      alt={alt}
      width={width}
      height={height}
      className={className}
      loading="lazy"
    />
  );
};

React/JSX component with alt as a required prop and a TypeScript definition — drop into your image component file.

Input button nameQuick win

Add a visible label or accessible name to every input button so assistive technologies can announce what the button does.

On Headless / Custom (I build in code)

  1. Search your codebase (HTML, JSX, TSX, Vue SFCs) for `<input type="submit"`, `<input type="button"`, and `<input type="reset"`.
  2. For every match, ensure the `value` prop/attribute is a non-empty, descriptive string.
  3. For icon-only or dynamically-rendered buttons where a visible label is not desired, add `aria-label="Descriptive action"` to the element.
  4. Add an automated accessibility test (e.g. jest-axe or Playwright with axe-core) to your CI pipeline to catch regressions.
  5. Verify in multiple screen readers (NVDA + Firefox on Windows, VoiceOver + Safari on macOS/iOS).
Code · Headless / Custom (I build in code) (html)
// CORRECT: visible label via value prop
<input type="submit" value="Complete Purchase" className="btn-checkout" />

// CORRECT: icon-only button with aria-label
<input
  type="submit"
  value=""
  aria-label="Search"
  className="btn-icon-search"
/>

// WRONG: empty value with no aria-label
// <input type="submit" value="" className="btn-checkout" />

React/JSX example — ensure the value prop is always a non-empty string; use aria-label for icon-only buttons.

LabelQuick win

Add a visible or programmatic label to every form input so assistive technologies can identify it for users.

On Headless / Custom (I build in code)

  1. In your component or template file, ensure every `<input>`, `<select>`, and `<textarea>` has one of: (a) a `<label for='x'>` with a matching `id='x'` on the input; (b) the input wrapped inside a `<label>` element; (c) an `aria-label='...'` attribute on the input; or (d) `aria-labelledby='id-of-visible-text'` pointing to existing text.
  2. Create a reusable `<FormField>` (React), a `field` component (Vue), or a partial (any framework) that always outputs a label + input pair, making unlabelled fields structurally impossible.
  3. Add an automated accessibility test (axe-core, jest-axe, Playwright with axe) to your CI pipeline to catch regressions before deploy.
  4. Verify in a browser with the axe DevTools extension.
Code · Generic HTML (html)
<!-- Method 1: Explicit label (recommended) -->
<label for="user-email">Email address</label>
<input type="email" id="user-email" name="email" autocomplete="email">

<!-- Method 2: Implicit (wrapped) label -->
<label>
  Email address
  <input type="email" name="email" autocomplete="email">
</label>

<!-- Method 3: aria-label (use when no visible label is desired, e.g. icon-only search) -->
<input type="search" name="q" aria-label="Search products">

<!-- Method 4: aria-labelledby (point to existing visible text on the page) -->
<h2 id="newsletter-heading">Subscribe to our newsletter</h2>
<input type="email" name="subscribe" aria-labelledby="newsletter-heading">

<!-- Visually hidden label (visible to screen readers, hidden on screen) -->
<style>
  .visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
</style>
<label for="search-input" class="visually-hidden">Search products</label>
<input type="search" id="search-input" name="q" placeholder="Search...">

Preferred method: explicit label linked by for/id. Place immediately before the input.

Landmark banner is top levelModerate effort

Move your site's `<header>` element (or `role="banner"` landmark) so it is a direct child of `<body>`, not nested inside another landmark region.

On Headless / Custom (I build in code)

  1. Open your root layout component or HTML shell (e.g., _app.jsx, layout.tsx, _document.jsx, or base.html).
  2. Ensure your `<header>` (or component that renders as `<header>`) is placed as a sibling of `<main>` at the top level of `<body>`, not nested inside `<main>` or any other element with a landmark role.
  3. Remove `role="banner"` from any nested header copies; only the outermost, body-level header should carry this role (it is implicit on `<header>` when used outside `<article>`/`<section>`).
  4. Run automated checks with axe-core in CI (axe-playwright, jest-axe) to prevent regression.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>My Store</title>
</head>
<body>

  <!-- CORRECT: <header> is a direct child of <body> -->
  <header>
    <a href="/"><img src="/logo.svg" alt="My Store home"></a>
    <nav aria-label="Primary">
      <ul>
        <li><a href="/shop">Shop</a></li>
        <li><a href="/about">About</a></li>
      </ul>
    </nav>
  </header>

  <main id="main-content">
    <!-- page content here -->
  </main>

  <footer>
    <!-- footer content here -->
  </footer>

</body>
</html>

Correct top-level structure: <header> is a direct child of <body>, not nested in any landmark.

Landmark main is top levelQuick win

Move the `<main>` element (or `role="main"`) so it is a direct child of `<body>`, not nested inside any other landmark element such as `<header>`, `<nav>`, `<aside>`, or `<footer>`.

On Headless / Custom (I build in code)

  1. In your root layout component or base HTML template, locate the element rendered as `<main>` or carrying `role='main'`.
  2. Ensure it is a direct child of `<body>` in the rendered DOM — confirm with browser DevTools (Elements tab → inspect the full DOM tree).
  3. Restructure your component tree so that Header, Nav, and Footer components are siblings of Main, not ancestors.
  4. Run `npx axe-core` or integrate axe-core into your CI pipeline to catch regressions automatically.
  5. Use the Accessibility Tree view in Chrome DevTools (Accessibility tab) to verify `<main>` appears as a top-level landmark with no landmark parent.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>My Store</title>
</head>
<body>

  <header>
    <nav aria-label="Primary navigation">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/shop">Shop</a></li>
      </ul>
    </nav>
  </header>

  <!-- CORRECT: <main> is a direct child of <body>, NOT inside <header> or any other landmark -->
  <main id="main-content">
    <h1>Welcome to My Store</h1>
    <p>Browse our latest products below.</p>
  </main>

  <footer>
    <p>&copy; 2024 My Store</p>
  </footer>

</body>
</html>

Correct top-level landmark structure — <main> is a direct child of <body>, sibling to <header> and <footer>.

Landmark no duplicate bannerModerate effort

Ensure your page has only one banner landmark (one `<header>` or `role="banner"` element that is not nested inside another landmark), so screen reader users can navigate your site reliably.

On Headless / Custom (I build in code)

  1. Search your codebase (components, layouts, partials) for every `<header`, `role="banner"`, and any component named 'Header' or 'PageHeader'.
  2. Ensure exactly one component renders a top-level `<header>` (or `role="banner"`) that is not nested inside `<main>`, `<article>`, `<section>`, `<aside>`, or `<nav>`.
  3. Rename inner-content headings sections to `<div>` with descriptive class names, or wrap them in a sectioning element so the browser treats the nested `<header>` as a section header, not a page banner.
  4. Add an automated accessibility test (e.g. jest-axe or Playwright + axe-core) to your CI pipeline to catch regressions.
  5. Deploy and validate with axe DevTools or Lighthouse.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Store Page</title>
</head>
<body>

  <!-- CORRECT: Single top-level <header> = one banner landmark -->
  <header>
    <a href="/"><img src="logo.svg" alt="Acme Store"></a>
    <nav aria-label="Main navigation">
      <ul>
        <li><a href="/shop">Shop</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/contact">Contact</a></li>
      </ul>
    </nav>
  </header>

  <main>
    <article>
      <!-- CORRECT: <header> nested inside <article> is NOT a banner landmark -->
      <header>
        <h1>Product Name</h1>
        <p>Posted: <time datetime="2024-01-01">January 1, 2024</time></p>
      </header>
      <p>Product description goes here.</p>
    </article>

    <!-- CORRECT: promotional strip uses <div>, not <header> -->
    <div class="promo-bar" role="region" aria-label="Promotional offer">
      <p>Free shipping on orders over $50!</p>
    </div>
  </main>

  <footer>
    <p>&copy; 2024 Acme Store</p>
  </footer>

</body>
</html>

Correct page structure: one top-level <header> as the banner, inner content sections use <div> or are wrapped in a sectioning element.

Landmark no duplicate mainQuick win

Find and remove every duplicate `<main>` element or `role="main"` attribute so that exactly one main landmark exists on each page.

On Headless / Custom (I build in code)

  1. Search your entire component and layout codebase (e.g., grep -r '<main\|role=.main' src/) for every occurrence of '<main>' elements or 'role="main"' attributes.
  2. Trace which components are composed into a single page render — layout shell, header component, footer component, sidebar, main content area — and ensure only the top-level content wrapper uses '<main>' or 'role="main"'.
  3. Replace all other occurrences with semantically appropriate elements: '<section>' for thematic content groupings, '<article>' for self-contained pieces, or '<div>' for purely presentational wrappers.
  4. Add an automated accessibility test (e.g., jest-axe or Playwright with axe-core) to your CI pipeline that asserts landmark-no-duplicate-main passes on every page template.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Page Title</title>
</head>
<body>

  <header>
    <nav aria-label="Primary navigation">
      <!-- site-wide navigation -->
    </nav>
  </header>

  <!-- ✅ Only ONE <main> element per page -->
  <main id="main-content">
    <h1>Page Heading</h1>
    <!-- primary page content goes here -->
  </main>

  <footer>
    <!-- footer content -->
  </footer>

</body>
</html>

Correct page structure: exactly one <main> element wrapping the primary content, with header and footer outside it.

Landmark one mainQuick win

Add a single `<main>` landmark element (or `role="main"`) to every page so assistive technologies can identify and navigate directly to the primary content.

On Headless / Custom (I build in code)

  1. In your root layout component or HTML shell (e.g. `_app.jsx`, `layout.tsx`, `default.html`), find the `<div>` wrapping your page-level content output.
  2. Replace that `<div>` with a `<main>` element (e.g. `<main id="main-content">`) or add `role="main"` if you must keep a `<div>` for styling reasons.
  3. Ensure only one `<main>` renders per page — if you have nested layout components, trace the full render tree to confirm no duplicates.
  4. Add a skip link in the topmost component: `<a href="#main-content" class="skip-link">Skip to main content</a>` pointing at the `id` on your `<main>`.
  5. Run `axe-core` in your CI pipeline (e.g. via `jest-axe` or Playwright/axe) to catch regressions automatically.
Code · Headless / Custom (I build in code) (html)
// components/Layout.jsx (React / Next.js)
export default function Layout({ children }) {
  return (
    <>
      <a href="#main-content" className="skip-link">
        Skip to main content
      </a>
      <header>{/* global nav */}</header>
      <main id="main-content">
        {children}
      </main>
      <footer>{/* global footer */}</footer>
    </>
  );
}

Next.js / React example — place in your root _app.jsx or layout.tsx. The id matches the skip-link href.

Link nameQuick win

Add a descriptive, screen-reader-accessible text label to every link on your store so assistive technology can announce its purpose.

On Headless / Custom (I build in code)

  1. Audit all <a> elements in your component library. Any link containing only an icon, SVG, or image with no visible text must receive an aria-label prop/attribute.
  2. For React: <a href='/cart' aria-label='View shopping cart'><CartIcon aria-hidden='true' /></a>
  3. For icon components (SVG), always pass aria-hidden='true' on the SVG when the parent link already has an aria-label, to avoid double-announcements.
  4. For image links, ensure the <img> has a descriptive alt attribute; if the link also needs a separate label, use aria-label on the <a> and set alt='' on the image to avoid duplication.
  5. Add a linting rule (eslint-plugin-jsx-a11y rule: anchor-has-content + anchor-is-valid) to your CI pipeline to catch regressions automatically.
  6. Run axe-core or Playwright accessibility audit as part of your automated test suite.
Meta viewportQuick win

Remove `user-scalable=no` (and any `maximum-scale` below 5) from your site's `<meta name="viewport">` tag so visitors can pinch-to-zoom on mobile devices.

On Headless / Custom (I build in code)

  1. Search your codebase for 'user-scalable' — it will be in a layout/shell component, an _document.js / _app.js (Next.js), a nuxt.config (Nuxt.js), an index.html, or a <head> component.
  2. Remove `user-scalable=no` / `user-scalable=0` and either remove `maximum-scale` or set it to 5.
  3. For Next.js: edit the <Head> in pages/_document.js or use the next/head component in your layout.
  4. For Nuxt.js: edit the head.meta array in nuxt.config.js/ts.
  5. Deploy, then run axe DevTools or Lighthouse to confirm the rule passes.
Code · Generic HTML (html)
<meta name="viewport" content="width=device-width, initial-scale=1">

Place this in the <head> of every HTML page. This is the correct, accessibility-compliant viewport tag.

Meta viewport largeQuick win

Remove or raise the `maximum-scale` value in your meta viewport tag so users can pinch-zoom on mobile devices.

On Headless / Custom (I build in code)

  1. Open the root HTML shell or document component — e.g., _document.js / _document.tsx in Next.js, app.html in SvelteKit, index.html in a Vite/CRA app, or your server-rendered base template.
  2. Search for the <meta name="viewport"> tag.
  3. Set the content attribute to: width=device-width, initial-scale=1 (no maximum-scale, no user-scalable=no).
  4. Rebuild and deploy. Verify with Chrome DevTools → device emulation that pinch-zoom is not blocked.
Code · Generic HTML (html)
<meta name="viewport" content="width=device-width, initial-scale=1">

Replace any existing <meta name="viewport"> tag in your <head> with this. Omitting maximum-scale and user-scalable entirely is the safest, most accessible choice.

Nested interactiveModerate effort

Remove or restructure nested interactive elements so that no focusable control (button, link, input, etc.) is placed inside another interactive control, and never rely on tabindex="-1" or aria-hidden="true" to hide an inner interactive element from assistive technologies.

On Headless / Custom (I build in code)

  1. Search your component codebase for patterns where interactive HTML elements are children of other interactive elements: `<a><button`, `<button><a`, `<a><input`, `<button><select`, etc. Use grep or your IDE's global search.
  2. Refactor each component: decide which element is the primary interactive control, keep it, and replace the other with a non-interactive element (`<span>`, `<div>`, `<svg aria-hidden='true'>`) that is styled to look identical.
  3. Remove any tabindex='-1' or aria-hidden='true' that was masking the inner element — these are not reliable fixes and are no longer needed.
  4. Add a lint rule (e.g., eslint-plugin-jsx-a11y rule 'no-interactive-element-to-noninteractive-role' and 'interactive-supports-focus') to your CI pipeline to catch regressions automatically.
  5. Run axe-core or Playwright accessibility checks in your test suite against the affected pages to confirm zero nested-interactive violations before merging.
Code · Headless / Custom (I build in code) (html)
// ❌ WRONG — anchor wrapping a button in JSX
// function ProductCard({ product }) {
//   return (
//     <a href={product.url}>
//       <button className="btn">View Product</button>
//     </a>
//   );
// }

// ✅ CORRECT — single anchor styled as a button
function ProductCard({ product }) {
  return (
    <a href={product.url} className="btn btn-primary">
      {/* Icon is decorative — aria-hidden keeps it invisible to screen readers */}
      <svg aria-hidden="true" focusable="false" width="16" height="16">
        <use href="#icon-eye" />
      </svg>
      View Product
    </a>
  );
}

Generic React/JSX component pattern — one interactive element per action, icons use aria-hidden.

Page has heading oneQuick win

Add a single, descriptive `<h1>` heading to every page so assistive technologies and search engines can identify the page's main topic.

On Headless / Custom (I build in code)

  1. Search your component/template files for page-level title output (e.g. <ProductTitle>, <PageHeader>) and confirm the rendered HTML element is <h1>.
  2. If using a UI library (MUI, Chakra, etc.), pass the component prop (e.g. component='h1' or as='h1') to ensure semantic HTML output.
  3. Make sure dynamic pages (product, category, blog, CMS) each have exactly one <h1> that contains the entity's primary name.
  4. Run axe-core programmatically in CI (npm install axe-core) to catch regressions automatically.
  5. Use React Testing Library or Playwright to assert document.querySelector('h1') is not null on every page type.
Code · Headless / Custom (I build in code) (html)
// React example — ProductPage component
export default function ProductPage({ product }) {
  return (
    <main>
      <h1>{product.name}</h1>
      {/* rest of product content */}
    </main>
  );
}

When using React/Next.js or similar, ensure the component that renders the page title outputs an <h1> element. Pass 'as' or 'component' props where relevant.

Presentation role conflictModerate effort

Remove conflicting ARIA attributes (global ARIA properties and tabindex) from any element that has been marked as purely presentational with role="presentation" or role="none".

On Headless / Custom (I build in code)

  1. Search your codebase (JSX, TSX, HTML templates) for role="presentation" and role="none" — e.g. grep -r 'role="presentation"' src/.
  2. For each match, inspect the element for any aria-* props/attributes or tabindex. Remove the conflicting attribute following the decision logic in the generic steps.
  3. For decorative images in React/Next.js/Nuxt, prefer <img alt="" /> or the framework's Image component with alt="" rather than role="none" + aria-label conflicts.
  4. Run automated tests (jest-axe, Playwright with axe-core) in CI to catch future regressions.
  5. Manually test with VoiceOver (Mac/iOS) and NVDA (Windows) to confirm screen readers skip the element silently.
Code · Generic HTML (html)
<!-- CORRECT: Decorative image — use empty alt, no role needed -->
<img src="decorative-swoosh.png" alt="" />

<!-- CORRECT: If role="none" is used on a layout table, remove all ARIA -->
<table role="none">
  <tr><td>Layout cell</td></tr>
</table>

<!-- CORRECT: Meaningful element — remove role="presentation" and use proper semantics -->
<img src="product-badge.png" alt="New arrival" />

<!-- BROKEN (do not do this): role="none" conflicts with aria-label -->
<!-- <img src="decorative-swoosh.png" role="none" aria-label="decorative swirl" /> -->

<!-- BROKEN (do not do this): role="presentation" conflicts with tabindex -->
<!-- <div role="presentation" tabindex="0">layout wrapper</div> -->

Decision examples: decorative image (correct), meaningful image (correct), broken pattern (what to avoid).

RegionModerate effort

Wrap all visible page content inside HTML landmark regions (e.g. `<header>`, `<main>`, `<nav>`, `<footer>`) so screen-reader users can navigate the page by section.

On Headless / Custom (I build in code)

  1. In your front-end framework (React, Vue, Next.js, Nuxt, Astro, etc.), identify your global layout component (e.g., 'Layout.jsx', 'DefaultLayout.vue', '_app.tsx').
  2. Replace generic `<div>` wrappers with the correct semantic HTML elements: `<header>` for the site header, `<nav aria-label='Main'>` for primary navigation, `<main>` for the primary content area, `<aside>` for sidebars, `<footer>` for the site footer.
  3. For any secondary navigation (breadcrumbs, pagination, footer links), use additional `<nav>` elements each with a unique `aria-label` to distinguish them.
  4. Ensure every `<section>` element has an accessible name via `aria-labelledby` pointing to a heading inside it, or `aria-label` if no heading is present.
  5. Run automated checks in CI using axe-core (npm package 'axe-core' or '@axe-core/react') to catch regressions before deployment.
Role img altModerate effort

Add a meaningful aria-label (or aria-labelledby) to every element with role="img" so screen readers can announce what the image represents.

On Headless / Custom (I build in code)

  1. Search your component library/repository for all instances of role="img" (e.g. grep -r 'role="img"' src/).
  2. For each meaningful instance, add aria-label="[description]" as a prop or hardcoded attribute.
  3. For SVG components: add a <title> as the first child, assign it a unique id, and set aria-labelledby={titleId} on the <svg role="img"> element — this is the most robust pattern.
  4. For decorative instances: replace role="img" with aria-hidden="true" and remove role entirely.
  5. Write a unit test or linting rule (e.g. eslint-plugin-jsx-a11y rule 'no-aria-hidden-on-focusable') to prevent regressions.
  6. Run axe-core in your CI pipeline (e.g. via jest-axe or Playwright/axe) to catch future violations automatically.
Code · Generic HTML (html)
<!-- Pattern 1: Inline element (icon font, emoji, span) -->
<span role="img" aria-label="Free shipping available">🚚</span>

<!-- Pattern 2: SVG element — use <title> + aria-labelledby (most robust) -->
<svg role="img" aria-labelledby="icon-free-shipping" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
  <title id="icon-free-shipping">Free shipping available</title>
  <use href="#icon-truck"></use>
</svg>

<!-- Pattern 3: SVG with aria-label only (simpler, slightly less support) -->
<svg role="img" aria-label="4.5 out of 5 stars" width="120" height="24" xmlns="http://www.w3.org/2000/svg">
  <!-- star paths here -->
</svg>

<!-- Pattern 4: div/section used as a decorative image — HIDE from screen readers -->
<div role="presentation" aria-hidden="true" class="decorative-banner"></div>

<!-- Pattern 5: referencing an existing visible caption -->
<figure>
  <div role="img" aria-labelledby="badge-caption" class="trust-badge"></div>
  <figcaption id="badge-caption">Verified Secure Checkout</figcaption>
</figure>

Apply to any HTML element using role="img" — choose the pattern that matches your element type.

Skip linkQuick win

Add a valid, focusable target element (with a matching `id`) for every "Skip to main content" link so keyboard and assistive-technology users can bypass repeated navigation.

On Headless / Custom (I build in code)

  1. In your root layout component (e.g., `_app.jsx`, `layout.tsx`, `App.vue`, `app.html`), add a skip link as the very first rendered element inside `<body>`.
  2. Pass `href="#main-content"` (or your chosen fragment) to the anchor.
  3. In the component that renders your main content area, set `id="main-content"` and `tabindex={-1}` (React/JSX) or `tabindex="-1"` (HTML/Vue/Svelte) on the root element.
  4. Add the visually-hidden-until-focused CSS globally (see snippet below).
  5. For Next.js, place the skip link in `app/layout.tsx` or `pages/_app.tsx`; for Nuxt, use `app.vue` or a global layout file; for SvelteKit, use `src/routes/+layout.svelte`.
  6. Verify with keyboard Tab and a screen reader (NVDA+Firefox or VoiceOver+Safari).
Svg img altModerate effort

Add a meaningful text alternative to every SVG image so screen readers can describe it to visually impaired shoppers.

On Headless / Custom (I build in code)

  1. Locate every `<svg>` element in your component library or template files.
  2. For meaningful SVGs: add `role="img"` to the `<svg>` tag; add `<title id="[unique-id]">Short descriptive text</title>` as the first child element inside `<svg>`; add `aria-labelledby="[unique-id]"` to the `<svg>` tag. If a longer description is needed, also add `<desc id="[desc-id]">Longer description</desc>` and include that id in `aria-labelledby` (space-separated).
  3. As a simpler alternative to the title element approach, add `aria-label="Descriptive text"` and `role="img"` directly on the `<svg>` opening tag.
  4. For decorative SVGs: add `aria-hidden="true"` and remove any existing `role` attribute and `<title>` element.
  5. In React/Vue/Angular component libraries, create a reusable SVG wrapper component that enforces either an `aria-label` prop or `aria-hidden` so developers cannot accidentally ship unlabelled meaningful SVGs.
  6. Run axe-core (programmatically or via browser extension) in CI to catch regressions.
Code · Generic HTML (html)
<!-- MEANINGFUL SVG — use title + aria-labelledby -->
<svg role="img" aria-labelledby="svg-logo-title" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <title id="svg-logo-title">Acme Co. logo — home</title>
  <!-- SVG path data here -->
  <circle cx="50" cy="50" r="50" fill="#e63946"/>
</svg>

<!-- MEANINGFUL SVG — simpler aria-label alternative -->
<svg role="img" aria-label="Free shipping badge" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
  <!-- SVG path data here -->
  <path d="M1 3h15v13H1z" fill="#2a9d8f"/>
</svg>

<!-- DECORATIVE SVG — hide from assistive technology -->
<svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 10">
  <!-- decorative divider path -->
  <rect width="200" height="10" fill="#f0f0f0"/>
</svg>

Preferred pattern for a meaningful inline SVG: <title> as first child + aria-labelledby on the root element.

TabindexModerate effort

Remove or replace all positive tabindex values (tabindex="1" or higher) on your page elements, using tabindex="0" for focusable elements and tabindex="-1" only when programmatic focus is needed.

On Headless / Custom (I build in code)

  1. Search your entire codebase for 'tabindex' (HTML attributes) and 'tabIndex' (JSX/React prop) using your IDE's global search or a command like 'grep -r "tabindex" src/'.
  2. Review every occurrence: remove the tabindex attribute entirely from native interactive elements (buttons, links, inputs), set non-native focusable elements to tabindex='0', and use tabindex='-1' only for elements that receive programmatic focus via JavaScript.
  3. Refactor your component or page source order so the DOM order matches the intended visual/reading order — this eliminates the need for positive tabindex values.
  4. Run an automated accessibility audit (e.g., axe DevTools browser extension or 'axe-core' in your CI pipeline) to confirm no elements with tabindex > 0 remain.
  5. Manually test keyboard navigation by tabbing through every interactive element on each page type and confirming the order is logical.
Code · Headless / Custom (I build in code) (html)
/* === React / JSX === */

// ❌ WRONG
<button tabIndex={3}>Checkout</button>

// ✅ CORRECT — native element, no tabIndex prop needed
<button type="submit">Checkout</button>

// ✅ CORRECT — custom focusable component
<div tabIndex={0} role="button" onClick={handleClick} onKeyDown={handleKeyDown}>
  Open Menu
</div>

// ✅ CORRECT — programmatic focus only (e.g., modal)
<div tabIndex={-1} ref={modalRef} role="dialog" aria-modal="true">
  Modal content
</div>


/* === Vue 3 template === */

// ❌ WRONG
// <button :tabindex="3">Checkout</button>

// ✅ CORRECT
// <button type="submit">Checkout</button>
// <div tabindex="0" role="button" @click="handleClick">Open Menu</div>


/* === Svelte === */

// ❌ WRONG
// <button tabindex="2">Checkout</button>

// ✅ CORRECT
// <button type="submit">Checkout</button>
// <div tabindex="0" role="button" on:click={handleClick}>Open Menu</div>

For React/JSX, use the camelCase 'tabIndex' prop. For Vue/Angular/Svelte, use the standard 'tabindex' attribute (or binding). These examples cover all frameworks.

Security (OWASP) · 18 fixes

Dmarc policy noneModerate effort

Strengthen your DMARC policy from p=none (monitor-only) to p=quarantine, then p=reject, so that emails fraudulently sent from your domain are blocked rather than silently allowed through.

On Headless / Custom (I build in code)

  1. Identify every service that sends email using your domain: transactional mail API (SendGrid, Postmark, Mailgun, SES), marketing ESP, helpdesk, CRM, etc.
  2. For each, complete their DKIM key publication (add the TXT record they provide to your DNS) and add their sending infrastructure to your SPF record.
  3. Parse your rua report data (use a DMARC report parser or a SaaS tool) until >95% of mail is aligned.
  4. Update your _dmarc TXT record via your DNS provider API or console: change p=none → p=quarantine → p=reject with monitoring intervals between each step.
Code · Generic HTML (dns)
v=DMARC1; p=quarantine; sp=quarantine; pct=100; rua=mailto:dmarc-reports@yourdomain.com; ruf=mailto:dmarc-failures@yourdomain.com; ri=86400; aspf=r; adkim=r; fo=1;

This is the DNS TXT record value for _dmarc.yourdomain.com — paste this as the record content at your DNS provider. Replace the rua/ruf addresses with your own reporting inbox or DMARC reporting service address.

Hsts disabledQuick win

Set a valid HTTP Strict-Transport-Security (HSTS) header with a max-age of at least 31536000 (one year) and includeSubDomains to force browsers to always use HTTPS for your store.

On Headless / Custom (I build in code)

  1. For Node.js/Express, use the helmet middleware: npm install helmet, then app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true }));
  2. For Next.js, add the header in next.config.js inside the headers() async function (see code snippet below).
  3. For Nginx, add to your server {} block: add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; — then nginx -t && nginx -s reload.
  4. For Apache, add to VirtualHost or .htaccess: Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
  5. For Cloudflare (as CDN in front of any stack): Dashboard → your domain → Security → Settings → HTTP Strict Transport Security → Enable, Max Age = 12 months, includeSubDomains = ON.
  6. Verify with: curl -I https://yourdomain.com | grep -i strict-transport-security
Code · Generic HTML (html)
Strict-Transport-Security: max-age=31536000; includeSubDomains

This is an HTTP response header — it cannot be set in HTML meta tags. It must be set by your server, CDN, or platform. Shown here for reference only.

Hsts max age too shortQuick win

Increase your HSTS max-age directive to at least 31536000 seconds (1 year) so browsers enforce HTTPS connections for a meaningful period.

On Headless / Custom (I build in code)

  1. Set the header in your server framework or hosting platform — do not rely on meta tags (HSTS is a server-sent HTTP header only).
  2. Next.js (next.config.js): module.exports = { async headers() { return [{ source: '/(.*)', headers: [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }] }] } }
  3. Vercel (vercel.json): { "headers": [{ "source": "/(.*)", "headers": [{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }] }] }
  4. Netlify (_headers file in /public): /* Strict-Transport-Security: max-age=31536000; includeSubDomains
  5. Cloudflare (Transform Rules): Dashboard → your domain → Rules → Transform Rules → Modify Response Header → Add rule: Header name = Strict-Transport-Security, Value = max-age=31536000; includeSubDomains. Apply to all HTTPS requests.
  6. Nginx: add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; inside the server block for port 443.
  7. Express.js (Node): Use the 'helmet' npm package — helmet() sets HSTS to max-age=15552000 by default; override with: app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true }));
Code · Generic HTML (http)
Strict-Transport-Security: max-age=31536000; includeSubDomains

This is an HTTP response header — it must be sent by your server, NOT as an HTML meta tag. The meta tag is not supported for HSTS.

Info disclosure serverQuick win

Remove or suppress the HTTP `Server` response header so your web server no longer advertises its software name and version number to the public internet.

On Headless / Custom (I build in code)

  1. For *nginx*: in `nginx.conf` inside the `http {}` block, add `server_tokens off;`. For a fully blank header, install the `headers-more-nginx-module` and add `more_clear_headers Server;` or `more_set_headers 'Server: ';`.
  2. For *Apache*: in `httpd.conf` or the relevant VirtualHost config, set `ServerTokens Prod` and `ServerSignature Off`. To completely remove the header, use `mod_headers`: `Header unset Server`.
  3. For *Node.js / Express*: call `app.disable('x-powered-by')` and, for the `Server` header if set by a reverse-proxy, handle it at the proxy layer. Use the `helmet` package (`app.use(helmet())`) which removes `X-Powered-By`; remove `Server` at the nginx/load-balancer layer.
  4. For *Cloudflare (any stack)*: Rules > Transform Rules > Modify Response Header > Add Rule > Remove header named `Server`. No server access required.
  5. For *AWS CloudFront*: use a CloudFront Function or Lambda@Edge on the `viewer-response` event to delete the `Server` header from the response before it reaches the client.
  6. Verify: `curl -I https://yourdomain.com` — `Server` header should be absent or generic.
Code · Generic HTML (html)
<!-- The Server header cannot be controlled from HTML.
     Apply the fix at your web server, CDN, or hosting control panel
     using one of the platform-specific configurations shown below. -->

There is no HTML tag to set or suppress the Server header — it is purely a server/CDN-level configuration. Use the server-specific snippets below.

Info disclosure x powered byQuick win

Remove or suppress the X-Powered-By HTTP response header so your server stops advertising which technology stack, hosting platform, or software version powers your store.

On Headless / Custom (I build in code)

  1. For Express.js (Node): use app.disable('x-powered-by'); in your app setup, or install the 'helmet' package (npm install helmet) and add app.use(helmet()); — helmet disables X-Powered-By by default.
  2. For Next.js: add a headers() function in next.config.js that returns { key: 'X-Powered-By', value: '' } with a remove action, or use a custom server with Express + helmet.
  3. For Apache: add 'Header unset X-Powered-By' in .htaccess or VirtualHost config (mod_headers must be enabled).
  4. For Nginx: add 'more_clear_headers X-Powered-By;' in the server {} block (requires ngx_headers_more module), or add 'fastcgi_hide_header X-Powered-By;' for PHP-FPM setups.
  5. For PHP: call header_remove('X-Powered-By'); at the top of your entry-point file (index.php) and set 'expose_php = Off' in php.ini.
  6. Verify by checking response headers in DevTools or curl -I https://yourdomain.com.
Code · Generic HTML (html)
<!-- X-Powered-By is an HTTP response header set by the server, not in HTML markup.
     Use the server-side code snippets below for your specific platform. -->

There is no HTML tag for this — it is a server-side HTTP response header. Use the server/framework snippets below.

Insecure cookieModerate effort

Set the HttpOnly, Secure, and SameSite flags on all session and authentication cookies to prevent them from being stolen or misused by attackers.

On Headless / Custom (I build in code)

  1. In Node.js/Express: use res.cookie('name', value, { httpOnly: true, secure: true, sameSite: 'strict' }) or configure express-session with { cookie: { httpOnly: true, secure: true, sameSite: 'strict' } }.
  2. In Python/Django: set SESSION_COOKIE_HTTPONLY = True, SESSION_COOKIE_SECURE = True, SESSION_COOKIE_SAMESITE = 'Strict' in settings.py.
  3. In Python/Flask: configure app.config with SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE='Strict'.
  4. In PHP: call session_set_cookie_params(['httponly'=>true,'secure'=>true,'samesite'=>'Strict']) before session_start(), or pass flags to setcookie().
  5. At the CDN/reverse-proxy layer (Nginx, Cloudflare, Vercel): add a response header transformation to inject missing flags as a universal backstop.
  6. Re-run your security scanner or check DevTools after deployment to confirm all session cookies show the three flags.
Code · Headless / Custom (I build in code) (javascript)
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,   // JS cannot access the cookie
    secure: true,     // HTTPS only — set to false in local dev if needed
    sameSite: 'strict' // No cross-site requests
  }
}));

// Or set a one-off cookie:
res.cookie('XSRF-TOKEN', csrfToken, {
  httpOnly: false,  // XSRF tokens MUST be readable by JS — omit HttpOnly for these
  secure: true,
  sameSite: 'Strict'
});

Node.js / Express — configure express-session with secure cookie flags.

Missing content security policyModerate effort

Add a Content-Security-Policy (CSP) HTTP response header to every page to block unauthorized scripts, styles, and resources from loading in your visitors' browsers.

On Headless / Custom (I build in code)

  1. Set the Content-Security-Policy header in your server's HTTP response for every route.
  2. Express.js: use the 'helmet' npm package — app.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"] } }));
  3. Next.js: add the header in next.config.js inside the headers() async function, or set it in middleware.ts using the NextResponse API.
  4. Nginx: add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';" always; in your server {} block.
  5. Apache: Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';" in .htaccess or VirtualHost config.
  6. Cloudflare Workers / Pages: set the header inside the fetch handler Response constructor or via _headers file.
Missing dmarcQuick win

Add a DMARC DNS TXT record at _dmarc.yourdomain.com to tell receiving mail servers how to handle emails that fail authentication checks for your domain.

On Headless / Custom (I build in code)

  1. Log in to your authoritative DNS provider (Cloudflare, Route 53, Google Cloud DNS, etc.) — whichever hosts your domain's NS records.
  2. Add a TXT record: Name = _dmarc (or _dmarc.yourdomain.com), TTL = 3600, Value = v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com
  3. Ensure your mail-sending service (SES, SendGrid, Postfix, etc.) has DKIM signing enabled and that an SPF record already exists for your domain.
  4. After 2–4 weeks of aggregate report review, tighten to p=quarantine, then p=reject. Consider adding pct=100 and a forensic ruf address for full coverage.
Code · Generic HTML (dns)
_dmarc.yourdomain.com. 3600 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com; ruf=mailto:dmarc-forensic@yourdomain.com; sp=none; adkim=r; aspf=r"

Add this as a DNS TXT record at your domain registrar or DNS provider. Replace yourdomain.com and the rua address with your actual domain.

Missing permissions policyQuick win

Add a Permissions-Policy HTTP response header to your store to explicitly restrict which browser features (camera, microphone, geolocation, etc.) pages and embedded third-party scripts are allowed to access.

On Headless / Custom (I build in code)

  1. Node.js / Express: Use the 'helmet' npm package — call helmet.permittedCrossDomainPolicies() and manually set the header, or use the permissionsPolicy option. Alternatively: res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()') in a global middleware.
  2. Next.js: Add the header in next.config.js under the headers() async function (see Next.js code snippet).
  3. Nuxt.js: Add the header in nuxt.config.js under routeRules or serverMiddleware, or via the Nitro server config.
  4. Apache / Nginx at origin: Use the config snippets below.
  5. CDN edge (Cloudflare Workers, Fastly, Vercel Edge): Inject the header in your edge function's response object.
  6. Verify with curl -I https://yourdomain.com | grep -i permissions-policy
Code · Generic HTML (meta tag — limited support) (html)
<!-- NOT recommended — use a real HTTP header instead. Shown for reference only. -->
<meta http-equiv="Permissions-Policy" content="camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()">

Meta tag delivery is not recommended — use HTTP headers instead. Included for reference only; browser support is partial.

Missing referrer policyQuick win

Add a Referrer-Policy HTTP response header set to strict-origin-when-cross-origin to control what URL information browsers send when users navigate away from your store.

On Headless / Custom (I build in code)

  1. For Node.js / Express: use the 'helmet' package — app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));
  2. For Next.js: add the header in next.config.js under the headers() async function (see code snippet below).
  3. For Netlify: add to netlify.toml under [[headers]] with for = '/*' and values = { Referrer-Policy = 'strict-origin-when-cross-origin' }.
  4. For Vercel: add to vercel.json under 'headers' for source '/(.*)'.
  5. For Apache: add Header always set Referrer-Policy "strict-origin-when-cross-origin" in your .htaccess or VirtualHost block.
  6. For Nginx: add add_header Referrer-Policy "strict-origin-when-cross-origin" always; in your server{} block.
  7. Verify by reloading the site and checking DevTools → Network → Response Headers.
Code · Headless / Custom (I build in code) (nginx)
server {
    # ... your existing config ...
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

Add inside the server{} block in your Nginx site configuration, then reload Nginx.

Missing security txtQuick win

Create and publish a /.well-known/security.txt file with at least a Contact: line so security researchers know how to report vulnerabilities responsibly.

On Headless / Custom (I build in code)

  1. Create the file at <webroot>/.well-known/security.txt on your server (or as a static asset in your framework's public directory).
  2. Ensure your web server or framework serves it with Content-Type: text/plain and does not block dotfile directories.
  3. For Next.js: place security.txt in /public/.well-known/security.txt — Next.js serves everything in /public as static files.
  4. For Nuxt.js: place security.txt in /public/.well-known/security.txt.
  5. Verify by visiting https://yourdomain.com/.well-known/security.txt.
Code · Headless / Custom (I build in code) (text)
Contact: mailto:security@yourdomain.com
Expires: 2026-12-31T23:59:00.000Z
Preferred-Languages: en
Policy: https://yourdomain.com/security-policy

For Next.js or Nuxt.js: save as /public/.well-known/security.txt — the framework serves everything in /public as a static file.

Missing strict transport securityQuick win

Add the Strict-Transport-Security (HSTS) response header to every HTTPS page so browsers always use an encrypted connection to your store.

On Headless / Custom (I build in code)

  1. Add the Strict-Transport-Security response header inside your server or middleware layer — see the code snippets below for Node.js (Express), Next.js, and generic server config formats.
  2. For Next.js: Add the header in next.config.js under the headers() async function.
  3. For Express.js: Use the helmet npm package (helmet() includes HSTS by default) or set the header manually with res.setHeader().
  4. For any reverse proxy (Nginx, Apache, Caddy) in front of your application: add the header at the proxy level so it applies to every response regardless of the backend.
  5. Set max-age=31536000 (one year) and includeSubDomains. Test with browser DevTools before enabling preload.
  6. After deploying, verify with securityheaders.com or hstspreload.org.
Code · Generic HTML (html)
<!-- HSTS cannot be set via an HTML meta tag. It MUST be sent as an HTTP response header. Use the server-level examples below. -->
<!-- Correct header value -->
<!-- Strict-Transport-Security: max-age=31536000; includeSubDomains -->

Meta tags cannot set HSTS — it must be a real HTTP response header. This is a reminder of the correct header value to use at the server level.

Missing x content type optionsQuick win

Add the `X-Content-Type-Options: nosniff` HTTP response header to every page and asset your store serves.

On Headless / Custom (I build in code)

  1. Next.js: in `next.config.js`, add to the `headers()` async function: `{ key: 'X-Content-Type-Options', value: 'nosniff' }` under the `headers` array for the `'/(.*)'` source pattern.
  2. Express.js / Node: use the `helmet` package — `app.use(helmet.noSniff())` — or manually `app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); next(); })`.
  3. Nginx: add `add_header X-Content-Type-Options 'nosniff' always;` in the `server {}` or `location {}` block.
  4. Apache: add `Header always set X-Content-Type-Options "nosniff"` inside `<IfModule mod_headers.c>` in `.htaccess` or your VirtualHost.
  5. Verify with `curl -I https://yourdomain.com` and confirm the header appears in the response.
Code · Headless / Custom (I build in code) (nginx)
server {
    # ... your existing config ...

    add_header X-Content-Type-Options 'nosniff' always;
}

Add inside the server {} block of your Nginx site config, then run: nginx -t && systemctl reload nginx

Missing x frame optionsQuick win

Add the X-Frame-Options HTTP response header set to DENY or SAMEORIGIN to prevent your store's pages from being embedded in iframes on other sites.

On Headless / Custom (I build in code)

  1. For Next.js: add headers() to your next.config.js as shown in the code snippets below.
  2. For Nuxt.js: add headers via the render.bundleRenderer or use the nuxt-helmet module, or configure at the Nitro server level.
  3. For Express.js / Node: use the 'helmet' npm package (helmet() automatically sets X-Frame-Options: SAMEORIGIN) or manually: res.setHeader('X-Frame-Options', 'SAMEORIGIN');
  4. For Apache: in httpd.conf or .htaccess, add inside <IfModule mod_headers.c>: Header always set X-Frame-Options 'SAMEORIGIN'.
  5. For Nginx: in your server or location block: add_header X-Frame-Options 'SAMEORIGIN' always;
  6. For Netlify: add to netlify.toml. For Vercel: add to vercel.json. See code snippets below.
  7. For Cloudflare (any platform behind it): Rules → Transform Rules → Response Header Modification → Set X-Frame-Options = SAMEORIGIN → Deploy.
Code · Generic HTML (http)
X-Frame-Options: SAMEORIGIN

This is an HTTP response header — it must be sent by the server, not a meta tag. It should appear in the HTTP response headers for every page.

Ssl cert expiring soonQuick win

Renew your SSL/TLS certificate before it expires to keep your store secure, trusted, and visible in search results.

On Headless / Custom (I build in code)

  1. Identify where TLS termination occurs: your origin server, a CDN (Cloudflare, Fastly, CloudFront), or a load balancer.
  2. If using Cloudflare: log in → select your domain → SSL/TLS → Overview. Cloudflare's Universal SSL auto-renews; confirm it is set to 'Full (Strict)' and the origin certificate is also renewed separately.
  3. If using Let's Encrypt with Certbot on a Linux server: run 'sudo certbot renew --dry-run' to test, then 'sudo certbot renew' to renew. Ensure a cron job or systemd timer runs this at least twice a day (standard Certbot install sets this up automatically).
  4. If using a paid CA certificate: download the new certificate bundle, update the certificate and private key paths in your Nginx/Apache/Caddy config, and reload the service.
  5. For Kubernetes environments, update the TLS Secret referenced by your Ingress resource, or use cert-manager with a Let's Encrypt ClusterIssuer for fully automated renewal.
  6. After renewal, verify the new expiry using: echo | openssl s_client -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates
Code · Headless / Custom (I build in code) (bash)
# Test renewal (dry run — no changes made)
sudo certbot renew --dry-run

# Perform the actual renewal
sudo certbot renew

# Reload Nginx to apply the new certificate
sudo systemctl reload nginx

# Verify the new expiry date
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates

Run on your Linux server to renew a Let's Encrypt certificate with Certbot and reload Nginx.

Vulnerable component axiosModerate effort

Update the Axios JavaScript library to its latest stable release to eliminate known security vulnerabilities in your storefront code.

On Headless / Custom (I build in code)

  1. In your project root, run `npm install axios@latest` (npm) or `yarn add axios@latest` (Yarn) or `pnpm add axios@latest` (pnpm) to update the dependency.
  2. Verify the update in package.json and package-lock.json (or yarn.lock) — the version should now reflect the latest release.
  3. Run `npm audit` to confirm no remaining Axios vulnerabilities are reported.
  4. Rebuild your production bundle (e.g. `npm run build`) and deploy to your hosting environment (Vercel, Netlify, AWS, etc.).
  5. Add Dependabot (GitHub) or Renovate Bot to your repository to automatically open PRs when new vulnerable versions are detected in the future.
Code · Headless / Custom (I build in code) (json)
{
  "dependencies": {
    "axios": "^1.7.9"
  }
}

Update your package.json to require the latest Axios, then run npm install.

Vulnerable component jqueryModerate effort

Update jQuery to the latest stable version (3.7+) to eliminate known cross-site scripting (XSS) and prototype-pollution vulnerabilities that attackers actively exploit.

On Headless / Custom (I build in code)

  1. Open your project's package.json (if using npm/yarn) and update the jQuery version: run 'npm install jquery@latest' or 'yarn add jquery@latest'.
  2. If jQuery is loaded via a CDN <script> tag in your HTML template, update the URL to 'https://code.jquery.com/jquery-3.7.1.min.js' and update the integrity hash from https://code.jquery.com/ (SRI hash provided on the jQuery CDN page).
  3. Search your entire codebase for 'jquery' to find any other references (e.g. in Webpack config, Rollup, Vite, or Browserify bundler config) and update them.
  4. Run your build process and deploy; use DevTools → Network to confirm only the updated version loads.
  5. Add jQuery to your dependency update schedule (e.g. Dependabot, Renovate) to be notified of future releases automatically.
Code · Headless / Custom (I build in code) (json)
{
  "dependencies": {
    "jquery": "^3.7.1"
  }
}

In your package.json dependencies, pin jQuery to the latest stable. Then run npm install or yarn.

X frame options weakQuick win

Replace the weak X-Frame-Options header value with DENY or SAMEORIGIN to prevent clickjacking attacks on your store.

On Headless / Custom (I build in code)

  1. For Node.js / Express: use the 'helmet' npm package — app.use(helmet.frameguard({ action: 'deny' })) — which sets X-Frame-Options: DENY and you can separately add a CSP header with frame-ancestors 'none'.
  2. For Next.js: add headers to next.config.js in the headers() async function (see code snippet below).
  3. For Vercel deployments: add a vercel.json file to your project root with a headers array entry for X-Frame-Options: DENY (see code snippet below).
  4. For Netlify deployments: add a netlify.toml file with a [[headers]] block, or add a _headers file to your publish directory (see code snippet below).
  5. For Apache: add to .htaccess — Header always set X-Frame-Options "DENY"
  6. For Nginx: add to server{} block — add_header X-Frame-Options "DENY" always;
  7. Always pair X-Frame-Options with Content-Security-Policy: frame-ancestors 'none'; for full modern browser coverage.
  8. Verify with: curl -I https://yourdomain.com | grep -i -E 'x-frame|content-security'
Code · Headless / Custom (I build in code) (javascript)
const express = require('express');
const helmet = require('helmet');

const app = express();

// Sets X-Frame-Options: DENY
app.use(helmet.frameguard({ action: 'deny' }));

// Adds Content-Security-Policy with frame-ancestors 'none'
app.use(
  helmet.contentSecurityPolicy({
    directives: {
      frameAncestors: ["'none'"],
    },
  })
);

// ... rest of your app

Node.js / Express: install helmet (npm install helmet) and add to your app entry point.

Site Lifecycle · 9 fixes

Domain expiryQuick win

Log in to your domain registrar, confirm your domain's expiry date, and enable auto-renew so your store never goes offline due to an expired domain.

On Headless / Custom (I build in code)

  1. Log in to your registrar (GoDaddy, Namecheap, Cloudflare Registrar, AWS Route 53, Google Domains/Squarespace Domains, etc.) and locate the domain.
  2. Enable auto-renew and confirm the payment method is current.
  3. If you manage DNS separately (e.g. Cloudflare DNS but registered elsewhere), remember that auto-renew must be set at the registrar — not the DNS provider.
  4. Consider setting a calendar reminder 90 days before expiry as a backup to auto-renew failures.
Code · Generic HTML (html)
<!-- No HTML change required. Domain renewal is managed at your domain registrar's dashboard (e.g. GoDaddy, Namecheap, Cloudflare Registrar). Log in, find the domain, enable auto-renew, and confirm a valid payment method is on file. -->

No code change is needed on your website — this fix is completed entirely in your registrar's dashboard. There is no markup or configuration file involved.

Gtm auditModerate effort

Install Google Tag Manager on your store and configure it to fire GA4 with full ecommerce event tracking (view_item, add_to_cart, purchase, and more).

On Headless / Custom (I build in code)

  1. Add the GTM loader snippet to your root layout component (e.g. _document.tsx in Next.js or app.html in SvelteKit) so it fires on every page.
  2. Push ecommerce events to window.dataLayer manually or via a data layer helper library (e.g. @next/third-parties for Next.js includes a GTM component).
  3. Map each key user action (product view, add to cart, begin checkout, purchase) to the corresponding GA4 ecommerce event schema, including the items array.
  4. In GTM, create GA4 Event tags triggered by each dataLayer.push event name and publish.
  5. Use GTM Preview and GA4 DebugView to validate every event fires with the correct parameters.
Code · Generic HTML (html)
<!-- Google Tag Manager (paste in <head>) -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>
<!-- End Google Tag Manager -->

<!-- Google Tag Manager (noscript) (paste immediately after <body>) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->

Replace GTM-XXXXXXX with your real Container ID. First snippet goes in <head>, second immediately after <body>.

Html langQuick win

Add a lang attribute to your site's <html> tag that matches the primary language of your store's content.

On Headless / Custom (I build in code)

  1. Open your root layout component or HTML shell template (e.g., app/root.jsx in Remix/Hydrogen, pages/_document.js in Next.js, app.html in SvelteKit, index.html in a static build).
  2. Add the lang attribute to the <html> element: <html lang="en"> (hardcoded) or dynamically bound to a locale variable for multi-language apps.
  3. For Next.js, set i18n.defaultLocale in next.config.js — Next.js automatically adds lang to <html> based on the active locale.
  4. For Nuxt.js with @nuxtjs/i18n, the module sets the lang attribute automatically from the active locale.
  5. Verify with browser DevTools → Elements panel that the rendered <html> tag carries the correct lang value.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Your Store</title>
</head>
<body>
  <!-- page content -->
</body>
</html>

Place this as the very first tag in your HTML document, before <head>.

Lifecycle products missing from sitemapQuick win

Add all canonical product URLs to your XML sitemap so search engines discover and index every product page quickly.

On Headless / Custom (I build in code)

  1. Build a dynamic sitemap endpoint (e.g., /sitemap.xml) in your application that queries your commerce backend or CMS for all published product slugs/handles at request time or via a build-time script.
  2. Paginate if needed: Google supports sitemap index files that reference multiple child sitemaps, each containing up to 50,000 URLs.
  3. Set the <lastmod> tag for each product URL to the product's last-updated timestamp from your backend.
  4. Reference your sitemap in /robots.txt with a 'Sitemap: https://yourdomain.com/sitemap.xml' directive.
  5. Submit in Google Search Console → Indexing → Sitemaps.
Code · Headless / Custom (I build in code) (javascript)
// app/sitemap.js  (Next.js 13+ App Router)
// Replace fetchAllProducts() with your actual data-fetching logic
// (Shopify Storefront API, BigCommerce GraphQL, Contentful, etc.)

export const dynamic = 'force-dynamic'; // or 'force-static' if products rarely change

async function fetchAllProducts() {
  // Example: fetch from your headless commerce API
  const res = await fetch('https://your-api.example.com/products?limit=10000', {
    next: { revalidate: 3600 }, // revalidate every hour
  });
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json(); // expects array of { handle: string, updatedAt: string }
}

export default async function sitemap() {
  const products = await fetchAllProducts();

  const productEntries = products.map((product) => ({
    url: `https://www.example.com/products/${product.handle}`,
    lastModified: new Date(product.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.8,
  }));

  return [
    {
      url: 'https://www.example.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    ...productEntries,
  ];
}

Next.js 13+ App Router: create app/sitemap.js — this auto-generates /sitemap.xml at build time from your product data source.

Lifecycle products too deepModerate effort

Move deeply buried product pages closer to the homepage by flattening your site's navigation structure so every product is reachable within 4 clicks (ideally 3) from the homepage.

On Headless / Custom (I build in code)

  1. Audit your routing/URL structure and internal link graph programmatically (e.g. with a custom crawler or Screaming Frog) to identify all pages with click depth > 3.
  2. Refactor your category data model: eliminate intermediate taxonomy nodes that exist only as organizational containers and have no meaningful content of their own.
  3. Update your sitemap generation logic so that all product URLs are listed at the sitemap root level, giving crawlers a direct path regardless of navigation depth.
  4. Add breadcrumb + internal link components on category and product pages that link back to parent categories and the homepage, providing short-circuit paths.
  5. On the homepage component, render a 'Featured Products' or 'Recently Added' section that dynamically queries and displays products from deep categories, providing a programmatic 1-click path.
  6. Implement automated depth-monitoring in your CI/CD pipeline so regressions are caught before deployment.
Code · Generic HTML (html)
<!-- Featured Products section on homepage or top category page -->
<section aria-label="Featured Products">
  <h2>Featured Products</h2>
  <ul>
    <li><a href="/products/product-name-1">Product Name 1</a></li>
    <li><a href="/products/product-name-2">Product Name 2</a></li>
    <li><a href="/products/product-name-3">Product Name 3</a></li>
  </ul>
</section>

Add direct internal links to deep products from your homepage or top-level category pages. Place this markup in a 'Featured Products' or 'Quick Links' section.

Mixed contentModerate effort

Ensure every resource (images, scripts, stylesheets, fonts, iframes) on your store is loaded over HTTPS, never HTTP, so browsers never block or warn about mixed content.

On Headless / Custom (I build in code)

  1. Audit all HTML templates, component files, and configuration for any hardcoded 'http://' asset URLs — images, scripts, stylesheets, fonts, iframes.
  2. Replace all hardcoded HTTP asset URLs with HTTPS equivalents, or use protocol-relative URLs (//) where the third-party host supports HTTPS.
  3. Ensure your Content Security Policy (CSP) includes 'upgrade-insecure-requests' as a meta tag or HTTP response header to auto-upgrade any missed HTTP sub-requests.
  4. Verify your CDN, image proxy, or asset pipeline is configured to serve assets over HTTPS.
  5. Deploy and test with browser DevTools Console → check for zero mixed-content warnings.
Code · Headless / Custom (I build in code) (html)
Content-Security-Policy: upgrade-insecure-requests

HTTP response header version of the Content-Security-Policy upgrade directive. Add this to your server config or middleware — it is stronger than the meta tag version.

Mobile viewportQuick win

Add a responsive viewport meta tag to every page so mobile browsers render your site at the correct width and scale.

On Headless / Custom (I build in code)

  1. Locate the root HTML shell or document component — e.g. `pages/_document.tsx` in Next.js, `app.html` in SvelteKit, `index.html` in a Vite/CRA app, or the root layout file in your framework.
  2. Add `<meta name="viewport" content="width=device-width, initial-scale=1">` inside the `<head>` element.
  3. In Next.js App Router, use the Metadata API instead: export a metadata object with `{ viewport: { width: 'device-width', initialScale: 1 } }` from your root layout.
  4. In Nuxt 3, set `viewport` inside `useHead()` or in `nuxt.config.ts` under `app.head.meta`.
  5. Redeploy and verify with `curl -s <your-url> | grep viewport` or Chrome DevTools.
Code · Generic HTML (html)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Page Title</title>
</head>
<body>
  <!-- page content -->
</body>
</html>

Place the viewport meta tag as early as possible in <head>, before any CSS or other meta tags.

Server versionQuick win

Remove or suppress HTTP response headers that advertise your server software name and version to the public internet.

On Headless / Custom (I build in code)

  1. Next.js: add a 'headers()' function in 'next.config.js' that returns a rule matching all paths ('source: "/(.*)")' with 'key: "X-Powered-By", value: ""' set to empty, or use 'poweredByHeader: false' in the config object.
  2. Express.js: call 'app.disable("x-powered-by")' immediately after initialising Express, and optionally add Helmet middleware ('npm install helmet; app.use(helmet())') which handles this and other security headers.
  3. Vercel deployments: add a 'headers' array in 'vercel.json' with source '/(.*)', setting X-Powered-By to an empty value or removing it (see code snippet).
  4. Netlify: add a '_headers' file in your publish directory with a '/*' route and 'X-Powered-By: ' (empty) line, or configure in 'netlify.toml'.
  5. Cloudflare Workers / Pages: use a custom Worker script to intercept responses and call 'response.headers.delete("X-Powered-By")' before returning.
  6. Verify with 'curl -I https://yourdomain.com' or DevTools that no version information is present.
Code · Headless / Custom (I build in code) (javascript)
const express = require('express');
const helmet = require('helmet');

const app = express();

// Remove X-Powered-By header (Express built-in)
app.disable('x-powered-by');

// Helmet sets/removes many security-relevant headers at once
// (includes hiding X-Powered-By as well as adding CSP, HSTS, etc.)
app.use(helmet());

// Your routes go here
app.get('/', (req, res) => {
  res.send('Hello, world!');
});

app.listen(3000);

Express.js entry file (e.g. server.js or app.js). Install Helmet for a comprehensive set of security headers: npm install helmet

Ssl expiryQuick win

Renew your TLS/SSL certificate before it expires and enable automatic renewal so your store never goes offline or shows security warnings.

On Headless / Custom (I build in code)

  1. Use Certbot (Let's Encrypt) for free, automated certificate management: install via your package manager and run `sudo certbot --nginx` or `sudo certbot --apache` for automatic configuration.
  2. Enable the systemd renewal timer: `sudo systemctl enable --now certbot.timer` and verify with `sudo systemctl status certbot.timer`.
  3. For containerised/cloud setups, use a sidecar like cert-manager (Kubernetes) or AWS Certificate Manager (ACM), which handle issuance and renewal automatically.
  4. Add a monitoring alert (e.g., UptimeRobot's SSL monitor, or a cron that emails you) to notify you if a certificate is within 21 days of expiry.
  5. For CDN-fronted architectures (Cloudflare, CloudFront), provision a certificate at the CDN edge layer as well as the origin, and verify both are renewed.
Code · Headless / Custom (I build in code) (nginx)
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    # HSTS (optional but recommended after cert is confirmed working)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}

Add to your nginx server block to force HTTPS after certificate renewal.