How to check for invalid elements in the <head>
Only eight elements are allowed in the <head>. If anything else turns up, such as an <img>, <iframe> or <div>, the browser decides the head has ended and treats every tag after it as part of the body. Google does the same: it ignores the elements that come after the invalid one, so a canonical, hreflang or robots tag below that point can be missed.
<title><meta><link><script><style><base><noscript><template>
Why one stray tag matters
Browsers don't throw errors on bad HTML. They repair it. When the HTML parser meets something that can't live in the head, it closes the head right there and starts the body, even though your </head> is further down.
A script that ends up in the body still runs, so that does no harm. SEO tags are different. Everything after the stray element is now in the body, and Google stops reading the head at that point, so those tags can be missed. Google's own guidance on valid page metadata says it treats an invalid element as the end of the head.
<head> <meta charset="utf-8"> <title>Blue Widgets</title> <img src="/pixel.gif"> <link rel="canonical" …> <meta name="robots" …> </head> <body> …
<head> <meta charset="utf-8"> <title>Blue Widgets</title> </head> ← closed early <body> <img src="/pixel.gif"> <link rel="canonical" …> ← now in body <meta name="robots" …> ← now in body
Same HTML, two views. This is also why the problem is easy to miss: DevTools shows you the repaired version on the right, not what you shipped.
What usually causes it
It's rarely a developer writing <img> in the head by hand. It's normally something pasted in or injected:
<noscript><iframe> belongs straight after <body>, but it often gets pasted into the head with the script part. With JavaScript on, browsers read it as text. A parser with JavaScript off ends the head at the <iframe>, and some SEO crawlers flag it, so move it anyway.<img>, added through a theme setting or a "header scripts" box.theme.liquid, WordPress plugins hooking into wp_head and printing a <div> or a stray <p>.} or a word left behind by a template error. Text that isn't whitespace ends the head too.How to check by hand
Three ways. Methods 1 and 3 read the HTML your server sends. Method 2 reads the page the browser built, which is where the damage shows.
1Read the page source 2 minutes
- Open the page and press
Ctrl+U(⌘+⌥+Uon a Mac) to see the source. - Press
Ctrl+Fand search for</head>(or<bodyif there isn't one). Everything above it is your head. - Read down from
<head>. Any tag that isn't one of the eight above, or any loose text, is the problem. Note which SEO tags come after it.
Fine on a short head. Tiring on a 400-line one full of inline scripts.
2Look for SEO tags that ended up in the body 1 minute
This flips the problem round: instead of finding the stray tag, look for the damage. Open DevTools, go to the Console and paste:
// SEO tags that the browser moved out of the head
document.querySelectorAll(
'body link[rel~=canonical i], body link[hreflang], body meta[name=robots i], body meta[name=googlebot i], body meta[name=description i], body meta[name=viewport i], body meta[charset], body base, body title:not(svg title)'
).forEach(el => console.log(el.outerHTML, '\n after:', el.previousElementSibling?.outerHTML.slice(0, 120)));
Nothing printed means nothing was pushed out. Anything printed is a tag search engines may be ignoring. The element shown after "after:" is usually the stray one, or close to it.
3Scan the server HTML with a snippet 1 minute
This fetches the page's HTML again and lists every tag in the head that isn't allowed:
// Lists elements in the server HTML head that don't belong there
const html = await (await fetch(location.href, { cache: 'no-store', headers: { Accept: 'text/html' } })).text();
// Drop comments and the contents of script-like elements first, then keep everything before the body.
const head = html
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<(script|style|noscript|template|title|textarea)\b[\s\S]*?<\/\1\s*>/gi, '')
.split(/<\/head\s*>|<body[\s>]/i)[0];
const ok = /^(html|head|meta|link|base|script|style|noscript|template|title)$/i;
const bad = [...head.matchAll(/<([a-z][\w-]*)/gi)].map(m => m[1].toLowerCase()).filter(t => !ok.test(t));
console.log(bad.length ? 'Not allowed in <head>: ' + [...new Set(bad)].join(', ') : 'Head looks clean');
It's a rough pattern match, not a real parser. It won't catch loose text or anything inside <noscript>, and some sites send different HTML to Googlebot than to your browser.
What about elements added by JavaScript?
A script can insert a <div> into the head after the page loads. Inserting it that way doesn't re-run the parser, so nothing gets moved and it stays in the head. It's still worth cleaning up, but the server HTML is the part that pushes your tags out, so start there. The exception is document.write, which old pixel tags still use: it writes into the parser while the page loads, and can end the head just like server HTML.
So if methods 2 and 3 both come back clean but a tool still reports an <iframe> or <img> in the head, a script almost certainly added it. To see what's there, paste:
// Elements in the live head that don't belong there (including ones added by scripts)
const ok = /^(meta|link|base|script|style|noscript|template|title)$/i;
[...document.head.children].filter(el => !ok.test(el.tagName))
.forEach(el => console.log(el.outerHTML.slice(0, 200)));
The src usually tells you which tag or vendor added it. With the server HTML clean and nothing printed by method 2, it's low priority: tidy it up through the tag manager or app settings when you can.
Two related head problems
<body>, whether a stray element pushed it there or a template or plugin printed it in the wrong place. Search engines read these from the head, so they may be ignored. Method 2 above lists them.<head> sections A template or plugin that outputs a whole second <head>. Browsers ignore the second <head> tag itself. If it comes before <body>, its tags are merged into the first head, which can leave two titles or two canonicals. If it's inside the body, for example a widget that prints a whole page, its tags land in the body and may be ignored. Either way, which tags count becomes hard to predict. Press Ctrl+U and search for <head: there should be one match (ignore <header).How to fix it
- Move the stray element into the body. For tag manager and pixel fallbacks, put the
<noscript>part straight after the opening<body>tag. - If you can't move it (a locked app or plugin), move your title, meta description, canonical, robots and hreflang tags above it, as near the top of the head as you can.
- Check again with method 2. The list should come back empty.
On Shopify, WordPress and Next.js
- Shopify: Go to Online Store > Themes, open Edit code and read the head in
theme.liquid. Apps often add code there, so if the stray element isn't in the file, check recently installed apps. - WordPress: Anything hooked to
wp_headcan print into the head. Disable plugins one at a time on a staging site until the stray element disappears, then check the theme'sheader.php. - Next.js: Since version 15.2, metadata from
generateMetadatacan be streamed and added to the<body>, so a check can report it outside the head. Next.js says Googlebot reads it correctly. To keep it in the head for every visitor, sethtmlLimitedBots: /.*/innext.config.js, though that can slow responses.
How crawlers report this
If a crawl sent you here, these are the labels to look for. Our guide to Screaming Frog and Sitebulb issues covers other common ones.
| Screaming Frog | Sitebulb | What it means |
|---|---|---|
| Validation: Invalid HTML Elements In <head> | <head> contains invalid HTML elements | Something that isn't allowed in the head, such as an image or iframe, ended it early. Tags after it may be ignored. |
| Validation: Multiple <head> Tags | No direct match | The HTML has more than one head, usually from a stray template include. |
| Page Titles: Outside <head> | Title tag outside of head | The title sits in the body, so it may not be used. |
| Meta Description: Outside <head> | Meta description outside of head | The description sits in the body, so it may not be used. |
| Canonicals: Outside <head> | Canonical outside of head | The canonical sits in the body, where Google ignores it. |
| Directives: Outside <head> | Meta robots found outside of <head> | A robots tag sits in the body. Find out why before you remove it, as a stray noindex can still do damage. |
Doing this on every page?
SEOSnip runs this check for you
Open the extension on any page and it reads the server HTML and the rendered page side by side. If something breaks the head, you get:
- The first stray element and its line in the server HTML
- Which SEO tags it pushed out of the head
- SEO tags that sit in the body for any other reason, with where each one is
- More than one
<head>in the server HTML, with the line of each - A ticket with that evidence attached, ready for a developer
- A "Check if fixed" button that re-runs the check after the change ships
SEO metadata appears outside the head after invalid markup
| Line | Element | Result |
|---|---|---|
| 14 | <img src="/pixel.gif"> | Ends the head |
| 15 | <link rel="canonical"> | Outside the head |
| 16 | <meta name="robots"> | Outside the head |