diff --git a/docs/guide/pages-routing/loaders.md b/docs/guide/pages-routing/loaders.md
index a3eb14044..05fca4214 100644
--- a/docs/guide/pages-routing/loaders.md
+++ b/docs/guide/pages-routing/loaders.md
@@ -29,6 +29,23 @@ export const loader = defineHandler(async (c) => {
Void preserves `Date`, `Map`, `Set`, `bigint`, `RegExp`, `URL`, `undefined`, and repeated or cyclic references in page props and shared data. These values keep their types during server rendering, hydration, navigation, and deferred loading. Return data directly from your loader; explicitly returning `Response.json()` uses ordinary JSON conversion instead. Functions and custom class instances cannot be sent as page data.
+## Response Status
+
+Use `c.status()` to render the current page with an HTTP error status, such as `403` or `503`. Return the page's props as usual:
+
+```ts
+// pages/maintenance.server.ts
+import { defineHandler } from 'void/handler';
+
+export const loader = defineHandler((c) => {
+ c.status(503);
+ c.header('Retry-After', '120');
+ return { message: 'Down for maintenance' };
+});
+```
+
+The status and headers apply to both the HTML document and client-side navigation responses. Client visits still display the page with its props and layouts. Middleware can also set the status before `await next()`. For pages with deferred props, set the status in the loader before streaming starts.
+
## Using the Data in Page Components
The page component receives the loader's result as props. Export the inferred type from the server file so the component stays in sync:
diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts
index 912df0681..aaf153536 100644
--- a/packages/void/src/pages/protocol.ts
+++ b/packages/void/src/pages/protocol.ts
@@ -289,6 +289,11 @@ async function renderPageHtml(
function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response {
return new Response(serializePageData(pageObj), {
+ // Hono's lazy c.res getter ignores the private status set by c.status().
+ // Use its response factory to read that status without changing our header merging.
+ // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407
+ // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659
+ status: c.newResponse(null).status,
headers: pageResponseHeaders(c, {
'X-VoidPages': 'true',
'X-VoidPages-Version': PAGES_PROTOCOL_VERSION,
@@ -581,6 +586,11 @@ function renderedResponse(
assetTags?: AssetTags,
): Response {
return new Response(result, {
+ // Hono's lazy c.res getter ignores the private status set by c.status().
+ // Use its response factory to read that status without changing our header merging.
+ // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407
+ // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659
+ status: c.newResponse(null).status,
headers: adjustDevCssCsp(
pageResponseHeaders(c, {
'content-type': contentType,
diff --git a/packages/void/test/integration/page-errors.test.ts b/packages/void/test/integration/page-errors.test.ts
index 5e93e8403..ebb549838 100644
--- a/packages/void/test/integration/page-errors.test.ts
+++ b/packages/void/test/integration/page-errors.test.ts
@@ -82,6 +82,21 @@ describe.each(frameworks)('Pages errors with %s', (framework) => {
`import { defineMiddleware } from 'void/handler'; export default defineMiddleware(async (c,next)=>{c.set('shared',{viewer:'alice',...(c.req.header('X-Test-No-Root') ? { noRoot: true } : {})}); c.header('X-Security','retained'); const guard=c.req.header('X-Test-Guard'); if(guard==='not-found') return c.notFound(); if(guard==='explicit') return c.text('Hidden by guard',404); await next(); if(guard==='after-not-found') c.res=await c.notFound();});`,
);
write('pages/style.css', 'nav { color: rgb(20, 80, 100); }');
+ write(
+ 'middleware/status.ts',
+ `import { defineMiddleware } from 'void/handler';
+import { HTTPException } from 'hono/http-exception';
+export default defineMiddleware(async (c, next) => {
+ const status = c.req.header('X-Test-Error-Status');
+ if (status === '403' || status === '503') {
+ c.header('Retry-After', '120');
+ throw new HTTPException(status === '403' ? 403 : 503, {
+ message: status === '403' ? 'Your account is banned' : 'Down for maintenance',
+ });
+ }
+ await next();
+});`,
+ );
const nav =
'';
let rootLayout = component(nav, '', true);
@@ -119,6 +134,16 @@ describe.each(frameworks)('Pages errors with %s', (framework) => {
write(`pages/_error.${extension}`, component('