diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md
index d53f44ef2..4b239a3de 100644
--- a/docs/guide/pages-routing/head.md
+++ b/docs/guide/pages-routing/head.md
@@ -80,13 +80,29 @@ export default defineMiddleware(async (c, next) => {
});
```
+`headDefaults` also accepts `titleTemplate`, which replaces the config's template for the requests the middleware runs on. Use it to give one section of the site its own title suffix:
+
+```ts
+// middleware/admin.ts
+import { defineMiddleware } from 'void';
+
+export default defineMiddleware(async (c, next) => {
+ if (c.req.path.startsWith('/admin')) {
+ c.set('headDefaults', { ...c.get('headDefaults'), titleTemplate: '%s | Admin' });
+ }
+ await next();
+});
+```
+
+A page returning `{ title: "Users" }` under `/admin` produces `
Users | Admin`. Other pages keep the config's template.
+
## Precedence
When multiple layers provide head data, they merge with this precedence:
| Field | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
-| `title` | Page wins. `titleTemplate` from config wraps the winning title. |
+| `title` | Page wins. `titleTemplate` from middleware, or else 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. |
diff --git a/packages/void/src/pages/head.ts b/packages/void/src/pages/head.ts
index edb9e301c..80c9f06d3 100644
--- a/packages/void/src/pages/head.ts
+++ b/packages/void/src/pages/head.ts
@@ -17,14 +17,14 @@ export type HeadConfig = HeadDescriptor & {
/**
* Merge head descriptors with precedence: page > middleware > config.
*
- * - `title`: page wins; `titleTemplate` from config wraps it (`%s` replaced)
+ * - `title`: page wins; `titleTemplate` from middleware, else config, wraps it (`%s` replaced)
* - `meta`: page overrides entries with same `name`/`property`; others appended
* - `link`/`script`: concatenated (config first, then middleware, then page)
* - `htmlAttrs`/`bodyAttrs`: shallow merge, page wins conflicts
*/
export function resolveHead(
configHead: HeadConfig | undefined,
- middlewareHead: HeadDescriptor | undefined,
+ middlewareHead: HeadConfig | undefined,
pageHead: HeadDescriptor | undefined,
): HeadDescriptor | undefined {
if (!configHead && !middlewareHead && !pageHead) {
@@ -36,7 +36,7 @@ export function resolveHead(
const title = pageHead?.title ?? middlewareHead?.title ?? configHead?.title;
if (title !== undefined) {
- const template = configHead?.titleTemplate;
+ const template = middlewareHead?.titleTemplate ?? configHead?.titleTemplate;
result.title = template ? template.replace('%s', title) : title;
}
diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts
index 912df0681..b5a1c93cd 100644
--- a/packages/void/src/pages/protocol.ts
+++ b/packages/void/src/pages/protocol.ts
@@ -427,7 +427,7 @@ async function renderPageFailure(
let head: HeadDescriptor | undefined;
// Invalid shared/head input is not repairable by trying another component.
try {
- head = resolveHead(configHead, c.get('headDefaults') as HeadDescriptor | undefined, undefined);
+ head = resolveHead(configHead, c.get('headDefaults') as HeadConfig | undefined, undefined);
serializePageData({ shared, head });
} catch (cause) {
throw new Error('Unable to render Pages error boundary', { cause });
@@ -700,7 +700,7 @@ async function runPageGet(
// Read at render time so per-route middleware can set them before next()
const shared = (c.get('shared') as Record | undefined) ?? {};
const pageHead = headFn ? headFn(c, props) : undefined;
- const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined;
+ const middlewareHead = c.get('headDefaults') as HeadConfig | undefined;
const head = resolveHead(configHead, middlewareHead, pageHead);
if (renderMode !== 'island') {
varyPagesResponse(c);
@@ -900,7 +900,7 @@ async function runPageMutation(
errors: Record | null,
): PageObject {
const shared = (c.get('shared') as Record | undefined) ?? {};
- const middlewareHead = c.get('headDefaults') as HeadDescriptor | undefined;
+ const middlewareHead = c.get('headDefaults') as HeadConfig | undefined;
const pageHead = headFn ? headFn(c, props) : undefined;
const head = resolveHead(configHead, middlewareHead, pageHead);
return {
diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts
index e50662469..a9ed0f540 100644
--- a/packages/void/src/runtime/handler.ts
+++ b/packages/void/src/runtime/handler.ts
@@ -1,7 +1,7 @@
import type { Context, Env, MiddlewareHandler, Next, TypedResponse } from 'hono';
import type { RedirectStatusCode } from 'hono/utils/http-status';
import type { RewriteDestination, RouteDestination, RouteUrl, RouteUrlInput } from 'void/routes';
-import type { HeadDescriptor } from '../pages/head.ts';
+import type { HeadConfig, HeadDescriptor } from '../pages/head.ts';
import type { AuthSession, AuthUser } from './auth.ts';
import { base64ToBytes } from './base64.ts';
import { executeComposedHandler, setHandlerComposition } from './handler-composition.ts';
@@ -36,7 +36,7 @@ type RewritePath = TypedRoutes extends true ? RouteUrl : RewriteDestination;
export interface CloudContextVariables {
user: AuthUser | null;
session: AuthSession | null;
- headDefaults?: HeadDescriptor;
+ headDefaults?: HeadConfig;
[key: string]: unknown;
}
diff --git a/packages/void/test/unit/pages-head.test.ts b/packages/void/test/unit/pages-head.test.ts
index 776ddcef5..d19ae88e1 100644
--- a/packages/void/test/unit/pages-head.test.ts
+++ b/packages/void/test/unit/pages-head.test.ts
@@ -43,6 +43,32 @@ describe('resolveHead', () => {
expect(resolveHead(config, middleware, undefined)?.title).toBe('Dashboard | My Site');
});
+ it('middleware titleTemplate wins over config', () => {
+ const config: HeadConfig = { titleTemplate: '%s | My Site' };
+ const middleware: HeadConfig = { titleTemplate: '%s | Admin' };
+ const page: HeadDescriptor = { title: 'Users' };
+ expect(resolveHead(config, middleware, page)?.title).toBe('Users | Admin');
+ });
+
+ it('applies middleware titleTemplate without a config template', () => {
+ const middleware: HeadConfig = { titleTemplate: '%s | Admin' };
+ const page: HeadDescriptor = { title: 'Users' };
+ expect(resolveHead(undefined, middleware, page)?.title).toBe('Users | Admin');
+ });
+
+ it('falls back to config titleTemplate when middleware sets none', () => {
+ const config: HeadConfig = { titleTemplate: '%s | My Site' };
+ const middleware: HeadConfig = { meta: [{ name: 'robots', content: 'noindex' }] };
+ const page: HeadDescriptor = { title: 'Users' };
+ expect(resolveHead(config, middleware, page)?.title).toBe('Users | My Site');
+ });
+
+ it('does not emit titleTemplate in the resolved head', () => {
+ const middleware: HeadConfig = { titleTemplate: '%s | Admin' };
+ const page: HeadDescriptor = { title: 'Users' };
+ expect(resolveHead(undefined, middleware, page)).toEqual({ title: 'Users | Admin' });
+ });
+
it('deduplicates meta by name — page wins', () => {
const config: HeadConfig = {
meta: [{ name: 'description', content: 'Config desc' }],
diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts
index 10d922081..e44a1f053 100644
--- a/packages/void/test/unit/routing-runtime.test.ts
+++ b/packages/void/test/unit/routing-runtime.test.ts
@@ -1184,6 +1184,33 @@ describe('typed Pages and SSR execution', () => {
title: 'loaded',
});
});
+ it('wraps the page title with a titleTemplate from middleware headDefaults', async () => {
+ const app = new Hono();
+ app.use(
+ '/admin/*',
+ defineMiddleware(async (c, next) => {
+ c.set('headDefaults', { ...c.get('headDefaults'), titleTemplate: '%s | Admin' });
+ await next();
+ }),
+ );
+ const options = {
+ ...pageOptions,
+ headConfig: { titleTemplate: '%s | My Site' },
+ load: async () => ({ loader: () => ({}), head: () => ({ title: 'Users' }) }),
+ };
+ app.get('/admin/users', createPageGetHandler(options));
+ app.get('/users', createPageGetHandler(options));
+ const title = async (path: string) => {
+ const response = await app.fetch(
+ new Request('https://app.test' + path, {
+ headers: { 'x-voidpages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION },
+ }),
+ );
+ return (decodePageData(await response.json()) as { head?: { title?: string } }).head?.title;
+ };
+ expect(await title('/admin/users')).toBe('Users | Admin');
+ expect(await title('/users')).toBe('Users | My Site');
+ });
it('redirects extensionless Pages requests to trailing slashes without changing APIs or files', async () => {
const app = new Hono();
const routing = createRoutingRuntime(app, { nodeTarget: true });