Skip to content

Simplifying the <head> with astro-helmet

Updated 15 February 2026
Published 30 June 2024
Astronaut's helmet with code reflected off the visor.

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>:

src/layouts/Layout.astro
<!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:

src/layouts/Layout.astro
---
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:

Terminal window
npm i astro-helmet

astro-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:

Basic example

In this example, a single headItems object is passed in as the prop of the same name:

src/layouts/Layout.astro
---
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.

src/layouts/Layout.astro
---
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>
src/pages/index.astro
---
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.

src/pages/blog/[...slug].astro
---
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.props
const { 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.

src/layouts/Layout.astro
---
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:

priorityitem
-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" />
51remaining <style>
60<link rel="preload" />
70<script src="" defer></script>
80<link rel="prefetch" />
90remaining <link>
100remaining <meta>
105<script type="application/ld+json">
110anything 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:

src/layouts/Layout.astro
---
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
}
back to notes