{"title":"Theme SEO and accessibility","slug":"theme-seo-accessibility","url":"https://support.storeconnect.com/articles/theme-seo-accessibility","url_markdown":"https://support.storeconnect.com/articles/theme-seo-accessibility.md","subtitle":null,"summary":"Build themes that rank well and work for everyone: the meta_data snippet, Open Graph tags, structured data, sitemaps, responsive images, WCAG 2.1 AA requirements, and a checklist of issues found in real theme audits.","type":"Developer_Documentation","video_url":"","keywords":"SEO, accessibility, meta_data snippet, Open Graph, structured data, breadcrumbs, sitemap, WCAG, alt text, ARIA, responsive images, srcset, skip link, heading hierarchy, color contrast","last_modified":"2026-08-21T07:12:35+0000","body_markdown":"Building a theme that ranks well in search engines and works for all users is not optional — it is part of shipping quality work. This article covers StoreConnect's built-in SEO features, responsive image best practices, and the WCAG 2.1 Level AA accessibility requirements that apply to storefront themes.\n\n## SEO\n\n### The `meta_data` snippet\n\nInclude `{% render \"meta_data\" %}` in your layout's `\u003chead\u003e`. This single snippet auto-generates the title, meta description, meta keywords, Open Graph tags, and a canonical URL for every page type.\n\n\n```liquid\n\n\u003chead\u003e\n  ...\n  {% render \"meta_data\" %}\n\u003c/head\u003e\n```\n\n\nWhen `meta_data` is included, do **not** manually add a `\u003ctitle\u003e` tag — the snippet provides it.\n\n**What it generates:**\n\n| Tag | Source |\n|-----|--------|\n| `\u003ctitle\u003e` | The `title` variable from the platform (auto-generated per page type) |\n| `\u003cmeta name=\"description\"\u003e` | The `meta_description` variable |\n| `\u003cmeta name=\"keywords\"\u003e` | The `meta_keywords` variable |\n| `\u003clink rel=\"canonical\"\u003e` | `current_request.canonical_url` (auto-normalized) |\n| `\u003cmeta property=\"og:type\"\u003e` | Auto-detected: `\"website\"`, `\"article\"`, or `\"page\"` |\n| `\u003cmeta property=\"og:title\"\u003e` | Same as `\u003ctitle\u003e` |\n| `\u003cmeta property=\"og:description\"\u003e` | Same as meta description |\n| `\u003cmeta property=\"og:url\"\u003e` | Canonical URL |\n| `\u003cmeta property=\"og:locale\"\u003e` | `current_store.locale` |\n| `\u003cmeta property=\"og:image\"\u003e` | Social image or primary image |\n| `\u003cmeta property=\"og:price:amount\"\u003e` | Product price (product pages only) |\n| `\u003cmeta property=\"og:price:currency\"\u003e` | Store currency (product pages only) |\n\nOn paginated pages, the title automatically appends \" - Page N\".\n\n### SEO properties on drops\n\nMost content drops expose SEO fields that merchants can set in the CMS:\n\n| Drop | SEO properties |\n|------|---------------|\n| ProductDrop | `meta_title`, `meta_description`, `meta_keywords`, `search_description`, `social_image`, `url`, `condition`, `upc` |\n| ArticleDrop | `meta_title`, `meta_description`, `meta_keywords`, `search_keywords`, `canonical_url` |\n| ProductCategoryDrop | `meta_title`, `meta_description`, `meta_keywords`, `social_image`, `google_product_category` |\n| ArticleCategoryDrop | `meta_title`, `meta_description`, `meta_keywords`, `social_image` |\n| PageDrop | `meta_title`, `meta_description` |\n| StoreDrop | `meta_title`, `meta_description`, `meta_keywords`, `social_image` |\n\n### Meta description defaults\n\nCMS meta description fields are often blank. Override the `meta_data` snippet to auto-generate descriptions from content fields when the CMS field is empty:\n\n\n```liquid\n\n{%- if meta_description == blank -%}\n  {%- if current_product != blank -%}\n    {%- assign meta_description = current_product.search_description\n        | default: current_product.summary_content | strip_html | truncate: 160 -%}\n  {%- elsif current_product_category != blank -%}\n    {%- assign meta_description = current_product_category.introduction_content\n        | strip_html | truncate: 160 -%}\n  {%- elsif current_article != blank -%}\n    {%- assign meta_description = current_article.summary_content\n        | default: current_article.introduction_content | strip_html | truncate: 160 -%}\n  {%- elsif current_page != blank -%}\n    {%- assign meta_description = current_page.body_content | strip_html | truncate: 160 -%}\n  {%- endif -%}\n  {%- if meta_description == blank -%}\n    {%- assign meta_description = current_store.meta_description -%}\n  {%- endif -%}\n{%- endif -%}\n```\n\n\nThis ensures every page has a meta description — Lighthouse flags missing descriptions as a critical SEO issue.\n\n### Canonical URLs\n\nThe platform auto-generates canonical URLs via `current_request.canonical_url`. These are normalized to remove tracking parameters, normalize casing and trailing slashes, and include the full domain. The `meta_data` snippet renders the `\u003clink rel=\"canonical\"\u003e` tag automatically.\n\n### Structured data (JSON-LD)\n\n#### Breadcrumbs\n\nThe `breadcrumbs` snippet renders both visible breadcrumb HTML and a `BreadcrumbList` JSON-LD script:\n\n\n```liquid\n\n{% render \"breadcrumbs\" %}\n```\n\n\nInclude this on product, category, article, and content pages.\n\n#### Product rich data\n\nThe `products/product/rich_data_json` snippet generates Product or ProductGroup JSON-LD markup:\n\n\n```liquid\n\n{% render \"products/product/rich_data_json\", product: current_product %}\n```\n\n\nInclude this on the product page. It includes name, SKU, GTIN, brand, description, images, price range, availability, condition, and URL.\n\n### Sitemaps and robots.txt\n\nThe platform auto-generates `/sitemap.xml` and `/robots.txt`. These require no theme configuration. Custom content pages are automatically included in the sitemap.\n\n### Merchant feeds\n\nAutomated product feeds are available at `/merchant_feeds/google.xml` and `/merchant_feeds/facebook.xml`. These are generated from product data with no theme work required.\n\n### Twitter/X cards\n\nTwitter card tags are not auto-generated. Add them to your layout if needed:\n\n\n```liquid\n\n\u003cmeta name=\"twitter:card\" content=\"summary_large_image\"\u003e\n\u003cmeta name=\"twitter:title\" content=\"{{ title }}\"\u003e\n\u003cmeta name=\"twitter:description\" content=\"{{ meta_description }}\"\u003e\n{% if current_product != blank %}\n  {% assign tw_image = current_product.social_image | default: current_product.image %}\n{% elsif current_article != blank %}\n  {% assign tw_image = current_article.social_image | default: current_article.hero_image %}\n{% else %}\n  {% assign tw_image = current_store.social_image | default: current_store.logo %}\n{% endif %}\n{% if tw_image != blank %}\n  \u003cmeta name=\"twitter:image\" content=\"{{ tw_image.huge_url }}\"\u003e\n{% endif %}\n```\n\n\n### SEO checklist\n\n- [ ] `{% render \"meta_data\" %}` in layout `\u003chead\u003e`\n- [ ] Meta description defaults added for when CMS fields are blank\n- [ ] `{% render \"breadcrumbs\" %}` on product, category, article, and content pages\n- [ ] `{% render \"products/product/rich_data_json\", product: current_product %}` on product page\n- [ ] `\u003chtml lang=\"{{ current_store.locale }}\"\u003e` on the `\u003chtml\u003e` element\n- [ ] One `\u003ch1\u003e` per page\n- [ ] Logical heading hierarchy (h1 \u003e h2 \u003e h3, no skipped levels)\n- [ ] Descriptive `alt` text on all images with fallback: `alt=\"{{ image.alt_text | default: product.name }}\"`\n- [ ] Store-level `meta_description` set in the CMS (used as final fallback)\n\n---\n\n## Responsive images\n\nGoogle uses mobile-first indexing — your site's mobile experience determines its search ranking. Always use `srcset` and `sizes` on content images rather than a fixed `src` alone.\n\n### Image sizes available\n\nThe platform generates images at nine named sizes:\n\n| Size | Max dimensions | Property | Typical use |\n|------|----------------|----------|-------------|\n| `pico` | 16×16 | `pico_url` | LQIP placeholders |\n| `icon` | 32×32 | `icon_url` | Tiny icons |\n| `tiny` | 50×50 | `tiny_url` | Cart line item thumbnails |\n| `small` | 100×100 | `small_url` | Thumbnails |\n| `thumb` | 240×240 | `thumb_url` | Product cards (mobile) |\n| `medium` | 480×480 | `medium_url` | Product cards (desktop) |\n| `large` | 640×640 | `large_url` | Product detail images |\n| `huge` | 1024×1024 | `huge_url` | Hero images |\n| `massive` | 2048×2048 | `massive_url` | Retina hero images |\n\n### Responsive image pattern\n\n\n```liquid\n\n\u003cimg src=\"{{ image.medium_url }}\"\n     srcset=\"{{ image.thumb_url }} 240w,\n             {{ image.medium_url }} 480w,\n             {{ image.large_url }} 640w,\n             {{ image.huge_url }} 1024w,\n             {{ image.massive_url }} 2048w\"\n     sizes=\"(max-width: 575px) 100vw,\n            (max-width: 767px) 50vw,\n            (max-width: 991px) 33vw,\n            25vw\"\n     alt=\"{{ image.alt_text | default: product.name }}\"\n     loading=\"lazy\"\n     decoding=\"async\"\u003e\n```\n\n\n### Loading strategy\n\n| Location | Strategy | Attributes |\n|----------|----------|------------|\n| Hero / above the fold | Eager load, high priority | `fetchpriority=\"high\"` (no `loading=\"lazy\"`) |\n| Product cards in grid | Lazy load | `loading=\"lazy\" decoding=\"async\"` |\n| Below-the-fold content | Lazy load | `loading=\"lazy\" decoding=\"async\"` |\n\n---\n\n## Accessibility\n\nTarget: **WCAG 2.1 Level AA** conformance. This means all content is perceivable, operable, understandable, and robust for users with disabilities — including those using screen readers, keyboard navigation, and assistive technologies.\n\n### Viewport meta tag\n\nThe layout must include:\n\n```html\n\n\u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n```\n\nNever use `maximum-scale=1` or `user-scalable=no` — these prevent pinch-to-zoom, which is a WCAG 1.4.4 violation.\n\n### Skip link\n\nAdd a \"skip to main content\" link as the first focusable element after `{{ theme_bar }}`:\n\n\n```liquid\n\n\u003cbody\u003e\n  {{ theme_bar }}\n  \u003ca href=\"#SC-Main\" class=\"skip-link\"\u003e\n    {{ \"accessibility.skip_to_content\" | t | default: \"Skip to main content\" }}\n  \u003c/a\u003e\n  {% render \"header\" %}\n  \u003cmain id=\"SC-Main\"\u003e\n    ...\n  \u003c/main\u003e\n\u003c/body\u003e\n```\n\n\nRequired CSS (the base theme does not include this — add it to your theme):\n\n```css\n\n.skip-link {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n.skip-link:focus {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: auto;\n  height: auto;\n  padding: 0.75rem 1.5rem;\n  margin: 0;\n  overflow: visible;\n  clip: auto;\n  white-space: normal;\n  background: #fff;\n  color: #000;\n  z-index: 99999;\n  font-size: 1rem;\n  text-decoration: underline;\n}\n```\n\n### Semantic HTML\n\nUse correct HTML elements for their purpose:\n\n- `\u003cheader\u003e` — site header, once per page\n- `\u003cnav\u003e` — navigation regions, with `aria-label` to distinguish multiple navs\n- `\u003cmain id=\"SC-Main\"\u003e` — primary content, once per page\n- `\u003cfooter\u003e` — site footer\n- `\u003csection\u003e` — thematic grouping with a heading\n\n### Heading hierarchy\n\n- One `\u003ch1\u003e` per page (the main content title).\n- Use heading levels sequentially — never skip from `\u003ch1\u003e` to `\u003ch3\u003e`.\n- Footer headings should use `\u003ch2\u003e` with a CSS class for visual sizing, not `\u003ch4\u003e`. Lighthouse flags level skips.\n\n### Image alt text\n\nEvery `\u003cimg\u003e` must have an `alt` attribute:\n\n- Content images — describe what is shown: `alt=\"{{ image.alt_text | default: product.name }}\"`\n- Decorative images — use empty alt: `alt=\"\"`\n- Decorative SVG icons — use `aria-hidden=\"true\" focusable=\"false\"`\n\n### Form accessibility\n\n\n```liquid\n\n\u003clabel for=\"email\"\u003e{{ form.email.label }}\u003c/label\u003e\n\u003cinput type=\"email\"\n       id=\"email\"\n       name=\"{{ form.email.name }}\"\n       value=\"{{ form.email.value }}\"\n       {% if form.errors.size \u003e 0 %}\n         aria-invalid=\"true\"\n         aria-describedby=\"email-error\"\n       {% endif %}\n       required\n       autocomplete=\"email\"\u003e\n{% if form.errors.size \u003e 0 %}\n  \u003cspan id=\"email-error\" class=\"error\"\u003e{{ form.errors.first }}\u003c/span\u003e\n{% endif %}\n```\n\n\n### Accessible navigation\n\n\n```liquid\n\n\u003cnav aria-label=\"{{ 'navigation.main' | t }}\"\u003e\n  \u003cul\u003e\n    {% for item in all_menus.main-menu.items %}\n      \u003cli\u003e\n        {% if item.items.size \u003e 0 %}\n          \u003cbutton aria-expanded=\"false\" aria-controls=\"submenu-{{ forloop.index }}\"\u003e\n            {{ item.title }}\n          \u003c/button\u003e\n          \u003cul id=\"submenu-{{ forloop.index }}\" hidden\u003e\n            {% for child in item.items %}\n              \u003cli\u003e\u003ca href=\"{{ child.url }}\"\u003e{{ child.title }}\u003c/a\u003e\u003c/li\u003e\n            {% endfor %}\n          \u003c/ul\u003e\n        {% else %}\n          \u003ca href=\"{{ item.url }}\"\u003e{{ item.title }}\u003c/a\u003e\n        {% endif %}\n      \u003c/li\u003e\n    {% endfor %}\n  \u003c/ul\u003e\n\u003c/nav\u003e\n```\n\n\n### Live regions\n\nUse `aria-live` for content that updates dynamically without a page reload:\n\n\n```liquid\n\n{%- comment -%} Cart count badge {%- endcomment -%}\n\u003cspan aria-live=\"polite\" aria-atomic=\"true\"\u003e\n  {{ current_cart.item_count }} {{ \"cart.items\" | t }}\n\u003c/span\u003e\n\n{%- comment -%} Flash messages {%- endcomment -%}\n\u003cdiv role=\"alert\"\u003e\n  {{ current_flash.notice }}\n\u003c/div\u003e\n```\n\n\n### Color contrast\n\nWCAG AA requires:\n- 4.5:1 contrast ratio for normal text\n- 3:1 contrast ratio for large text (18px+ or 14px+ bold)\n- 3:1 for UI components and graphical objects\n\nCreate a CSS variable for brand colors on dark surfaces:\n\n```css\n\n:root {\n  --color-primary-on-dark: #E0944A; /* lighter tone that passes 4.5:1 on dark backgrounds */\n}\n```\n\n### Icon-only buttons\n\nEvery icon-only button or link must have an accessible label:\n\n\n```liquid\n\n\u003cbutton aria-label=\"{{ 'navigation.open_menu' | t }}\"\u003e\n  \u003csvg aria-hidden=\"true\" focusable=\"false\"\u003e...\u003c/svg\u003e\n\u003c/button\u003e\n```\n\n\n### Keyboard navigation\n\n- All interactive elements must be keyboard-focusable.\n- Focus indicators must be visible — add `:focus-visible` styles.\n- Dropdowns and modals must close on Escape and return focus to the trigger element.\n- Use `tabindex` only when necessary; never use values greater than 0.\n\n## Common pitfalls from real theme audits\n\nThese recurring issues are found across StoreConnect themes via Lighthouse audits:\n\n**Images without alt text.** Always include `alt` with a fallback: `alt=\"{{ image.alt_text | default: product.name }}\"`. The `alt_text` property comes from the CMS media record — if unset, fall back to the parent object's name.\n\n**Heading hierarchy violations.** Footer headings are commonly `\u003ch4\u003e`, which skips levels from the page's `\u003ch1\u003e`. Use `\u003ch2\u003e` with a CSS class for visual sizing instead. Never choose heading level based on desired font size — use CSS for that.\n\n**SVG icons missing `aria-hidden`.** Decorative SVG icons (hamburger, close, chevron, search, cart) must have `aria-hidden=\"true\" focusable=\"false\"`. If the icon is inside a button with `aria-label`, the icon is decorative.\n\n**Flash messages not announced.** The flash/notice snippet needs `role=\"status\"` on notices and `role=\"alert\"` on alerts for screen readers to announce them.\n\n**Links with no discernible name.** Icon-only links (cart, social media) that hide their text on mobile need `aria-label` so the mobile experience has an accessible name.\n\n**Invalid HTML in menus.** `\u003cul\u003e` elements must only contain `\u003cli\u003e` children. Close buttons or logos inside a `\u003cul\u003e` wrapped in a `\u003cdiv\u003e` produce invalid HTML. Wrap them in `\u003cli\u003e` elements instead.\n\n**`meta_data` snippet limitations.** CMS meta fields are often blank. Override the `meta_data` snippet to generate fallback descriptions from content fields — this single change fixes the most common Lighthouse SEO failure.\n\n**Lighthouse with theme preview.** Testing via `?theme-preview=` adds latency from the redirect and injects a preview bar with its own accessibility issues. These are platform artifacts, not theme issues. Account for them when interpreting scores.\n\n## Accessibility checklist\n\n- [ ] `\u003chtml lang=\"{{ current_store.locale }}\"\u003e` present\n- [ ] Skip-to-content link as first focusable element (with visually-hidden CSS)\n- [ ] One `\u003ch1\u003e` per page, logical heading hierarchy (footer uses `\u003ch2\u003e` not `\u003ch4\u003e`)\n- [ ] All images have `alt` attributes with fallback (`alt_text | default: name`)\n- [ ] Decorative SVGs have `aria-hidden=\"true\" focusable=\"false\"`\n- [ ] All form inputs have associated `\u003clabel\u003e` elements\n- [ ] Flash messages use `role=\"status\"` (notices) and `role=\"alert\"` (alerts)\n- [ ] Icon-only buttons and links have `aria-label`\n- [ ] Dropdowns and accordions use `aria-expanded`\n- [ ] Modals use `role=\"dialog\"` with `aria-modal=\"true\"` and `aria-labelledby`\n- [ ] Dynamic content updates use `aria-live`\n- [ ] All interactive elements are keyboard accessible\n- [ ] Visible focus indicators on all focusable elements\n- [ ] Color contrast meets WCAG AA (4.5:1 normal text, 3:1 large text)\n- [ ] Dark-surface link colors use an accessible variant of the brand color\n- [ ] Menu `\u003cul\u003e` elements contain only `\u003cli\u003e` children"}