Head management
Set page titles and metadata with head(), middleware or site-wide defaults. Page values win over middleware, and middleware wins over config.
On this pageSet a title and meta tags per page · Add site-wide defaults · Set head values from middleware
Page titles and metadata
This step sets a page's title and metadata. Export head() from the .server.ts file. It gets the Hono context and loader props, and returns a HeadDescriptor.
// pages/posts/[slug].server.ts
import { defineHandler, defineHead } from 'void';
export interface Props {
post: { title: string; excerpt: string; slug: string };
}
export const loader = defineHandler<Props>(async (c) => {
const post = await getPost(c.req.param('slug'));
return { post };
});
export const head = defineHead<Props>((c, props) => {
return {
title: props.post.title,
meta: [
{ name: 'description', content: props.post.excerpt },
{ property: 'og:title', content: props.post.title },
],
};
});head() runs server-side only, after the loader resolves.
Site defaults
This step sets site-wide defaults in void.config.ts.
{
"head": {
"titleTemplate": "%s | My Site",
"htmlAttrs": { "lang": "en" },
"meta": [
{ "charset": "utf-8" },
{ "name": "viewport", "content": "width=device-width, initial-scale=1" }
]
}
}titleTemplate wraps the page title. %s becomes the title from head(). A page returning { title: "About" } with the template above produces <title>About | My Site</title>.
Middleware defaults
This step sets headDefaults in middleware for every page. The example runs a theme script before first paint.
// middleware/01.head.ts
import { defineMiddleware } from 'void';
export default defineMiddleware(async (c, next) => {
c.set('headDefaults', {
script: [
{
innerHTML: `const t = localStorage.getItem("theme");
if (t) document.documentElement.dataset.theme = t;`,
},
],
});
await next();
});Precedence
When several layers set head data, they merge in this order:
| Field | Behavior |
|---|---|
title | Page wins. titleTemplate from config wraps the winning title. |
meta | Deduplicates by name or property. Page entries override matching keys, and non-conflicting entries are appended. |
link | Concatenated: config first, then middleware, then page. |
script | Concatenated: config first, then middleware, then page. |
htmlAttrs | Shallow merge, page wins conflicts. |
bodyAttrs | Shallow merge, page wins conflicts. |
HeadDescriptor
interface HeadDescriptor {
title?: string;
meta?: Array<{
name?: string;
property?: string;
content?: string;
charset?: string;
}>;
link?: Array<{ rel: string; href: string; [key: string]: string | undefined }>;
script?: Array<{ src?: string; innerHTML?: string; [key: string]: string | undefined }>;
htmlAttrs?: Record<string, string>;
bodyAttrs?: Record<string, string>;
}Markdown pages
Markdown pages build head tags from frontmatter. No .server.ts file is needed.
---
title: Getting Started
description: Learn how to use Void
---
Your content here...This produces <title>Getting Started</title> and <meta name="description" content="Learn how to use Void">, with titleTemplate applied if configured.
Client-side updates
Titles, meta tags and links update on client-side navigation.
script, htmlAttrs and bodyAttrs are SSR-only. Client-side navigation does not reapply them.