SEO & Head Metadata¶
MageObsidian fills in the head metadata Magento leaves empty: a canonical URL on every page, Open Graph and Twitter Card properties, a meta description derived from the page itself, the modern robots directives, and a Web App Manifest. Everything is server-rendered into the cached HTML β no extra JavaScript, nothing computed in the browser.
The metadata lives in MageObsidian_Storefront and is written through Magento's own Page\Config, so core does the escaping and the rendering. Every piece is a toggle: a store already running an SEO extension turns off the half that duplicates it.
Upgrade note: the head now carries the page asset collection¶
Until this version the theme's root template printed only headContent and headAdditional. It never printed headAssets, which is the only thing that renders Magento's page asset collection.
Everything that goes through Page\Config::addPageAsset() / addRemotePageAsset() was generated, stored β and silently dropped. That included:
- the canonical the core emits on catalog pages (with
catalog/seo/*_canonical_tagon), - the native
<link rel="icon">/<link rel="shortcut icon">, - the RSS
<link rel="alternate">, - and whatever the merchant configured in Design β HTML Head β Scripts and Style Sheets.
root.phtml now prints it. This is a fix, but it changes what an existing store emits in its head, so plan the upgrade with that in mind: measured on this project's demo, it adds four tags and between 448 and 530 bytes per page. No RequireJS, no jQuery, no Luma CSS β those pipelines are excluded from Obsidian themes elsewhere, so the collection simply does not contain them.
The one thing worth checking before you deploy is the last item on that list: if design/head/includes has something in it, it is being served now and it was not before. Read the Head includes section below before you assume that is free.
Canonical URL¶
Magento only emits a canonical on catalog pages, and only with the catalog SEO flags on. CMS pages, the home page and search results never get one. MageObsidian emits it on every page that does not already have one.
A canonical set by the core or by another extension is never overwritten. The module inspects the page asset collection first; if a canonical is already claimed, it writes nothing.
Query parameters: an allowlist¶
Everything not on the list is dropped, tracking parameters included. The default is p,q:
pstays because a paginated listing has to point at itself. If page 2 canonicalises onto page 1, the products only reachable from page 2 leave the index.qstays because without it every search result canonicalises onto the same empty URL.
An allowlist is used rather than a blocklist because a blocklist ages: today it is utm_*, gclid and fbclid, tomorrow it is something else.
A pagination parameter of 1 and any listed parameter with an empty value are dropped as well, so ?p=1 never produces a second URL for the first page.
URL suffix and the trailing slash¶
The path is normalised without its trailing slash, because Magento answers 200 for both /about-us and /about-us/ β leaving both would mean two indexable URLs for one page.
The exception is a store whose catalog/seo/category_url_suffix or catalog/seo/product_url_suffix is /. There the slash is part of the address, so the path is left exactly as it is.
Configuration¶
| Setting | Path | Default |
|---|---|---|
| Emit a Canonical URL | mage_obsidian/seo/canonical_enabled |
1 |
| Query Parameters Kept in the Canonical | mage_obsidian/seo/canonical_query_params |
p,q |
Stores β Configuration β MageObsidian β SEO.
Open Graph and Twitter Card¶
With social metadata on, the head carries, derived from the page's own title, description and imagery:
| Property | Source |
|---|---|
og:type |
product on a product page, website everywhere else |
og:site_name |
store name |
og:locale |
general/locale/code |
og:title |
the page title |
og:description |
the page's meta description |
og:url |
the resolved canonical β including one set by the core |
og:image |
the entity's image, then the fallback share image, then the store logo |
twitter:card |
summary_large_image |
twitter:site |
the configured account, omitted when empty |
twitter:title / twitter:description / twitter:image |
as above |
og:url deliberately uses the resolved canonical rather than the module's own: if the core canonicalises a product page to a URL, the shared URL has to be that same one.
It complements, it does not duplicate¶
On a product page MageObsidian_Catalog already emits its own Open Graph block (opengraph.general, from the ProductOpenGraph view model): og:type, og:title, og:url, og:image, og:description and the product:price:* pair.
Rather than hardcoding that list, the module asks the block what it emits. A di.xml map pairs a block name with the layout argument holding its view model, and the object is queried through getProperties(); whatever it claims is left to it:
If that block grows a property tomorrow, the detection follows on its own. A store running a third-party SEO extension adds one line here and its properties stop being duplicated too.
Configuration¶
| Setting | Path | Default |
|---|---|---|
| Emit Open Graph and Twitter Card Metadata | mage_obsidian/seo/social_meta_enabled |
1 |
| Fallback Share Image | mage_obsidian/seo/social_image |
empty (falls back to the store logo) |
| Twitter Account | mage_obsidian/seo/twitter_site |
empty (twitter:site omitted) |
The fallback share image should be 1200Γ630 or larger.
Meta description¶
Magento repeats the store default description on every page that has none of its own. When the fallback is on, the module replaces it with something about the actual page:
- the entity's own meta description wins;
- otherwise the entity's content is summarised β HTML stripped (
<script>and<style>with their contents, Page Builder{{...}}directives too), whitespace collapsed, cut at a word boundary around 160 characters; - if the page already carries something specific β another extension, a layout handle β it is left alone.
The module only steps in when the page is repeating the store default, and the comparison runs on normalised text, so the HTML escaping setMetadata() applies does not defeat it.
| Setting | Path | Default |
|---|---|---|
| Derive the Meta Description from the Page | mage_obsidian/seo/meta_description_fallback |
1 |
Meta robots¶
Magento writes INDEX,FOLLOW and stops there. The module appends the three directives that control how much of the page a search engine may show:
Two rules keep it safe:
- A page that is
NOINDEXis never touched. The extension bails out on sight of the directive, so a page kept out of the index stays exactly as Magento wrote it. - It only writes when it actually adds something. A directive already present is not repeated, and when there is nothing to add the value is left untouched.
| Setting | Path | Default |
|---|---|---|
| Extra Robots Directives | mage_obsidian/seo/robots_directives |
max-image-preview:large,max-snippet:-1,max-video-preview:-1 |
Clear the field to leave the meta robots exactly as Magento wrote it.
Web App Manifest¶
The manifest is derived from store configuration β name, base URL, logo, favicon, colours β so it is served by a controller rather than shipped as a static file, and it varies per store view.
Endpoint: /mage-obsidian-storefront/manifest/, linked from the head with <link rel="manifest">.
Notes worth knowing:
- The media type is
application/manifest+json, notapplication/json. - It is cacheable for a day by the browser, the CDN and Varnish. Magento's full page cache does not reach it β that only covers
Result\Pageβ so the HTTP headers are the mechanism here. iconsappears once a logo or favicon is configured; without either, the property is omitted rather than emitted empty.- Turned off, the endpoint answers 404, not an empty manifest, and the
404does not advertise itself as cacheable for a day.
| Setting | Path | Default |
|---|---|---|
| Serve a Web App Manifest | mage_obsidian/seo/manifest_enabled |
1 |
| Manifest Display Mode | mage_obsidian/seo/manifest_display |
standalone |
| Manifest Theme Colour | mage_obsidian/seo/manifest_theme_color |
#ffffff |
| Manifest Background Colour | mage_obsidian/seo/manifest_background_color |
#ffffff |
Head includes: native Magento, and render-blocking¶
Design β HTML Head β Scripts and Style Sheets (design/head/includes) is a native Magento capability. Whatever is pasted there is inserted into the <head> verbatim, on every page of the store view.
Everything in that field is render-blocking¶
This is the part that gets underestimated:
- a
<script src>with neitherasyncnordeferstops the HTML parser until it has been fetched, parsed and executed; - a
<link rel="stylesheet">blocks the first paint until the sheet arrives.
On a stack that measures FCP and LCP in milliseconds, a single third-party tag in that field can cost more than the entire rest of the page. It is the one place in the admin where a merchant can undo the whole front-end budget with a paste.
The framework does not rewrite it¶
That field is merchant content on a native Magento contract, and whoever pastes into it owns what it does. MageObsidian leaves it alone. Both flags below ship off, so out of the box the behaviour is exactly Magento's.
Two opt-in flags exist for merchants who have looked at their own content and decided:
| Setting | Path | Default |
|---|---|---|
| Defer Scripts In Head Includes | mage_obsidian/head/includes_defer_scripts |
0 |
| Defer Stylesheets In Head Includes | mage_obsidian/head/includes_defer_styles |
0 |
Stores β Configuration β MageObsidian β Frontend β HTML Head Includes.
Deferring scripts β and when not to¶
With the flag on, an external <script src> gets defer. async, defer and type="module" tags are already non-blocking and are left alone; an inline script is never touched, because there is no safe way to defer one and it may be exactly what has to run first.
Do not turn this on if the field holds a tag that has to run blocking. The two common cases both live in this exact field:
- A/B testing anti-flicker snippets. Deferred, they cause the very flash of the original content they exist to prevent.
- Consent managers. Deferred, the banner arrives after the page β and after the tags it was supposed to gate.
The positional rule¶
An external script is deferred only if no inline <script> follows it in the same field. The canonical analytics snippet is a pair:
A deferred script runs after the document is parsed, so it would run after that inline call β lib would not exist yet and the page would break. The rule is positional, not global:
That check only sees this one field. If an inline script in a template, in another module, or in a Page Builder widget depends on a script pasted here, the framework has no way to know. That is the limit of the heuristic, and it is the reason the flag is off by default.
Deferring stylesheets β and when not to¶
With the flag on, a <link rel="stylesheet"> becomes a preload that is promoted to a stylesheet once it arrives, with a <noscript> fallback:
The swap runs from a single nonced <script> emitted once at the end, never from an inline onload attribute β under an enforced CSP the attribute is dropped and the sheet would stay a preload that never applies. It listens for load and falls back to DOMContentLoaded, both { once: true }.
The page paints before that CSS applies. If the sheet styles anything above the fold, the shopper sees a flash of unstyled content and the layout jumps when it lands, which counts against Cumulative Layout Shift. Only turn it on when you know the sheet styles content below the fold or is purely cosmetic. It is the same reason the theme only defers its own stylesheet on pages that inlined critical CSS β see Performance.
Two details keep the conversion honest:
media="print"is skipped. It never blocked the screen render, so converting it would gain nothing and cost something: after the swap it would apply to the screen.media,integrity,crossorigin,referrerpolicy,idandtitleare carried over to both the preload and the<noscript>.integrityandcrossoriginare valid onrel=preload as=styleand the browser verifies the hash there, so Subresource Integrity keeps working end to end.
The escape hatch¶
Any tag carrying data-obsidian-blocking is left exactly as written, whatever the flags say:
Everything else in the field β <meta>, <link rel="preconnect">, HTML comments, loose text, inline scripts β comes out byte for byte as it went in. The blob is never parsed and re-serialised; only the attribute list of a matching open tag is rewritten in place.
The advice that actually applies¶
Before turning either flag on, the right move is almost always to take the script out of head includes and load it properly from the theme, where it can be ordered, bundled, deferred deliberately and measured. These flags are damage control for content you do not own or cannot move β not a substitute for loading a script correctly.
Native surfaces the theme keeps¶
Under a MageObsidian theme the framework's module manager reports every Magento_* module as "output disabled", and that manager is wired into the core's layout and page-layout file sources. The effect is not "the theme adds nothing to that screen": the core's layout file is dropped altogether. A core route that no MageObsidian module re-declares answers 200 with an empty body, or with the theme chrome and nothing inside, and no log says so.
The storefront therefore re-declares the core surfaces a visitor, a crawler or an operator still reaches by URL. Each lives in the module that owns the area, keeps the native block and renders with a Twig template in the theme.
| Surface | Route or page layout | Declared in | What it serves |
|---|---|---|---|
robots.txt |
robots_index_index |
module-storefront |
The instructions configured under Design βΊ Search Engine Robots, as text/plain, followed by the sitemap lines |
| Shipment tracking | shipping/tracking/popup |
module-sales |
Carrier, number and status of every track, and the store contact when the carrier answers nothing. The shipment email and the account's shipment page link to it |
| RSS feed list | /rss |
module-storefront |
The active feeds, one link each. 404 while RSS is off |
| Wish list sharing with RSS | wishlist_email_rss |
module-wishlist |
The "include RSS link" checkbox shows only with RSS Feeds βΊ Wish List active, and a share that asks for the link never fails |
| Login as Customer landing | loginascustomer/login/index |
module-customer |
A meta refresh to the customer account, with a manual link for whoever disabled it. No script |
| Full-width page layouts | cms-full-width, category-full-width, product-full-width |
theme-base, under Magento_PageBuilder/ (page_layout/*.xml for the structure, layouts.xml for the label the admin and the page builder recognise) |
The columns of 1column or 2columns-left with the page-main--full-width class on the content area, which the theme styles without side margins |
The page layouts live in the theme under Magento_PageBuilder/ on purpose. Theme sources are not filtered, and that folder disappears with the module, at the same moment the admin stops offering those layouts.
Two core routes are deliberately not kept, and the parity registry of storefront-verification records what each answers under the theme: review/product/listAjax, replaced by the review island and linked from nowhere, and /swagger, a development tool served only outside production mode.
When a page under the theme answers with an empty <main>, suspect a dropped core handle before anything else. The parity registry lists every core handle and page layout together with who re-declares it, and refuses an "out of scope" entry that does not say what the route answers.
Next Steps¶
- Structured Data (JSON-LD) β the schema.org half of the same story.
- Performance β critical CSS, the native build flags and Varnish.