Simplifying the <head> with astro-helmet
| Updated | 15 February 2026 |
| Published | 30 June 2024 |
Adding tags to the <head> of an Astro project is fairly trivial. You can add tags like <title>, <meta>, <link>, and <script> directly to the <head> in your layout component. The Astro docs recommend writing a single <head> tag in a layout component. See using <head> in a component.
One problem I had to solve soon after starting building this site in Astro, was how to get page specific head tags into the head in the Layout component. There are two simple ways to achieve this:
1. Use a <slot>:
<!doctype html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <slot name="head" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg"> </head> <body> ... </body></html>2. Use props to pass the data for the tags in to the Layout component:
---interface Props { title: string}
const { title } = Astro.props---
<!doctype html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{title}</title> <link rel="icon" type="image/svg+xml" href="/favicon.svg"> </head> <body> ... </body></html>For a nice implementation of this, see the Astro Starter Kit: Blog BaseHead.astro component
Both of these solutions became messy quickly. I wanted to:
- manage the head tags in a more structured way
- merge the head tags from the layout and page components
- deduplicate meta tags
- control the order of the tags in a consistent and configurable way
I built astro-helmet, a utility for managing the document head of Astro projects. It provides a simple and flexible API for adding head items from multiple components and controlling their order.
Using astro-helmet
To use astro-helmet, first install it with npm:
npm i astro-helmetastro-helmet can be used in a variety of ways, covering all use cases I could think of. There are two props that can be passed in to the component:
headItems: HeadItems | HeadItems[]options: Options
Basic example
In this example, a single headItems object is passed in as the prop of the same name:
---import Helmet from 'astro-helmet'
const headItems = { title: 'My Site Title', base: [{ href: 'https://example.com' }], meta: [ { name: 'description', content: 'My site description' }, { property: 'og:type', content: 'website' } ], link: [{ rel: 'stylesheet', href: 'styles.css' }], style: [{ innerHTML: 'body { color: red; }' }], script: [{ innerHTML: 'console.log("Hello, world!")' }], noscript: [{ innerHTML: 'Please enable JavaScript' }]}---
<!doctype html><html lang="en"> <Helmet {headItems} /> <body> ... </body></html>This example doesn’t have much application, as most of the time you will want to define the title and other tags in the page component. If you have no layout specific head tags, you could define the headItems in the page component, then pass them in as a prop.
You can also use the type for headItems in the layout or page component:
import type { HeadItems } from 'astro-helmet'
const headItems: HeadItems = {}Merging tags from multiple components
A more likely use case, and one that utilises astro-helmet more powerfully, is defining one headItems object in your layout component, and another in your page component.
The headItems prop can take an array of HeadItems and merge them together. Meta tags are deduplicated by name, property, http-equiv, and charset — the last tag with a matching key takes precedence. Meta tags without any of these keys (e.g. itemprop-only tags) are never deduplicated.
---import Helmet from 'astro-helmet'import type { HeadItems } from 'astro-helmet'
interface Props { headItems: HeadItems}
const layoutHeadItems: HeadItems = { title: 'My Site Title', base: [{ href: 'https://example.com' }], meta: [ { name: 'description', content: 'My site description' }, { property: 'og:type', content: 'website' } ], link: [{ rel: 'stylesheet', href: 'styles.css' }], style: [{ innerHTML: 'body { color: red; }' }], script: [{ innerHTML: 'console.log("Hello, world!")' }], noscript: [{ innerHTML: 'Please enable JavaScript' }]}
const { headItems: pageHeadItems } = Astro.props---
<!doctype html><html lang="en"> <Helmet headItems={[layoutHeadItems, pageHeadItems]} /> <body> ... </body></html>---import type { HeadItems } from 'astro-helmet'import Layout from '@/layouts/Layout.astro'
const headItems = { title: 'Home Page', meta: [ { name: 'description', content: 'Home page description' }, { property: 'og:type', content: 'website' } ]}---
<Layout {headItems}> <main> ... </main></Layout>This will result in the following <head>:
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <base href="https://example.com"> <title>Home Page</title> <script>console.log("Hello, world!")</script> <link rel="stylesheet" href="styles.css"> <style>body { color: red; }</style> <meta name="description" content="Home page description"> <meta property="og:type" content="website"> <noscript>Please enable JavaScript</noscript></head>Example preloading an image
If you are optimising LCP, you might find that a <link rel="preload"> tag is helpful for loading an image asset.
This example demonstrates how astro-helmet can be used with a [...slug].astro component to cleanly add dynamic head items when rendering a collection. We use getImage() from 'astro:assets' to generate the image srcset. We then include this in headItems.link array as a rel="preload" item.
---import { getCollection } from 'astro:content'import { getImage } from 'astro:assets'import Layout from '@/layouts/Layout.astro'
export async function getStaticPaths() { const blogEntries = await getCollection('blog') return blogEntries.map((entry) => ({ ... }))}
const { entry } = Astro.propsconst { Content } = await entry.render()
const lcp = await getImage({ src: entry.data.cover, format: 'webp', width: 736, densities: [1.5, 2, 3], loading: 'eager', decoding: 'sync'})
const headItems = { title: entry.data.title, link: [ { rel: 'canonical', href: 'https://example.com' }, { rel: 'preload', imagesrcset: lcp.srcSet.attribute, as: 'image' } ]}---
<Layout {headItems}> <main> {lcp && <img srcset={lcp.srcSet.attribute} {...lcp.attributes} />} <Content /> </main></Layout>This can be used with the Layout.astro component from the previous example.
Example preloading a font
This example assumes you have a font in your public/fonts. See Using custom fonts from the Astro docs.
const headItems = { link: [ { rel: 'preload', as: 'font', type: 'font/woff2', crossorigin: 'anonymous', href: '/fonts/InterVariable.woff2' } ]}This is the result added to the <head>:
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href="/fonts/InterVariable.woff2">Example inlining script
Inlining a script in the head can be useful for adding functionality that is required before the page is rendered. This example demonstrates how to do this.
Note the use of ?raw to import the minified script as a string. This is then added as the innerHTML of a script tag.
---import scriptForHead from '@/lib/scriptForHead.min.js?raw'
const headItems = { script: [{ innerHTML: scriptForHead }]}---This is the result added to the <head>:
<script>{scriptForHead}</script>JSON-LD / Structured Data
Use the jsonLd property to add JSON-LD structured data to your pages. This is useful for providing search engines with structured information about your content.
const headItems: HeadItems = { title: 'My Article', jsonLd: { '@type': 'Article', headline: 'My Article', author: { '@type': 'Person', name: 'Ryan' }, datePublished: '2025-12-01' }}This renders a <script type="application/ld+json"> tag with @context set to https://schema.org automatically. JSON.stringify() is handled internally, and any </script> sequences in values are escaped to prevent XSS.
For multiple blocks, pass an array:
const headItems: HeadItems = { title: 'My Article', jsonLd: [ { '@type': 'Article', headline: 'My Article' }, { '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://example.com/' }, { '@type': 'ListItem', position: 2, name: 'My Article' } ] } ]}JSON-LD composes naturally with layout + page merging — each source contributes blocks that render as separate <script type="application/ld+json"> tags. JSON-LD renders at priority 105, placing it after regular meta tags.
Controlling the order of tags
By default, astro-helmet orders the head tags in a sensible way, based on the recommended <head> tag order in Harry Roberts’ “Get Your Head Straight” talk. The default order is:
| priority | item |
|---|---|
| -4 | <meta charset=""> |
| -3 | <meta name="viewport"> |
| -2 | <base href=""> |
| -1 | <meta http-equiv=""> |
| 0 | <title> |
| 10 | <link rel="preconnect" /> |
| 20 | <script src="" async></script> |
| 30 | <style> where innerHTML.includes(‘@import’) |
| 40 | <script> |
| 50 | <link rel="stylesheet" /> |
| 51 | remaining <style> |
| 60 | <link rel="preload" /> |
| 70 | <script src="" defer></script> |
| 80 | <link rel="prefetch" /> |
| 90 | remaining <link> |
| 100 | remaining <meta> |
| 105 | <script type="application/ld+json"> |
| 110 | anything else |
You can override this default order by specifying a priority on any head item. For example:
const headItems = { // priority 1 will move the script to just below the <title> script: [{ src: '/scripts/importantScript.js', priority: 1 }]}You can also provide a custom applyPriority function in the options to fully customise the tag order. See the Options section below for details.
Options
astro-helmet accepts an optional options prop which allows you to customise its behavior
interface Options { omitHeadTags?: boolean applyPriority?: (tag: Tag) => Required<Tag>}omitHeadTags
By default, astro-helmet will render the <head> opening and closing tags around the head items. Set this to true if you want it to only render the inner tags.
applyPriority
A function that takes a head tag and returns it with a priority property added. This allows you to fully customise the order of the rendered tags. See the default implementation for an example.
Example with options
Here’s an example using both options:
---import Helmet from 'astro-helmet'
const headItems = { title: 'My Site Title' }
function customPriority(tag) { // Render meta tags before title if (tag.tagName === 'meta') return { ...tag, priority: -1 } // Render Open Graph meta tags last if (tag.property?.startsWith('og:')) return { ...tag, priority: 1000 } // Fall back to a sensible default return { ...tag, priority: tag.priority ?? 100 }}
const options = { omitHeadTags: true, applyPriority: customPriority}---
<!doctype html><html lang="en"> <head> <Helmet {headItems} {options} /> </head> <body> ... </body></html>Appendix
Type definitions
type BaseItem = { [key: string]: any priority?: number}
type ContentItem = BaseItem & { innerHTML?: string}
type JsonLdItem = { '@type': string '@context'?: never [key: string]: any}
type HeadItems = { title?: string base?: BaseItem[] meta?: BaseItem[] link?: BaseItem[] style?: ContentItem[] script?: ContentItem[] noscript?: ContentItem[] jsonLd?: JsonLdItem | JsonLdItem[]}
type TagName = | 'title' | 'base' | 'meta' | 'link' | 'style' | 'script' | 'noscript'
type Tag = (BaseItem | ContentItem) & { tagName: TagName priority?: number}