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/http-errors-playground.test.ts b/packages/void/test/integration/http-errors-playground.test.ts
new file mode 100644
index 000000000..cc55d13cf
--- /dev/null
+++ b/packages/void/test/integration/http-errors-playground.test.ts
@@ -0,0 +1,345 @@
+import { join } from 'node:path';
+import spawn from 'cross-spawn';
+import type { Browser, Page } from 'playwright';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test';
+import {
+ createIsolatedPlayground,
+ killChild,
+ startServer,
+ type IsolatedPlayground,
+ type StartedServer,
+} from '../../../../test/integration-helpers.ts';
+import { launchPagesBrowser } from '../../../../test/pages-browser.ts';
+import { PAGES_PROTOCOL_VERSION } from '../../../edge/src/pages-protocol.ts';
+import { deserializePageData } from '../../src/pages/serialize.ts';
+
+const repository = join(import.meta.dirname, '../../../..');
+const pagesHeaders = {
+ 'X-VoidPages': 'true',
+ 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION,
+};
+const renderedExamples = [
+ {
+ path: '/loaders/status/403',
+ status: 403,
+ heading: 'Status set in a loader',
+ message: 'Your account is banned',
+ retryAfter: '120',
+ },
+ {
+ path: '/loaders/status/503',
+ status: 503,
+ heading: 'Status set in a loader',
+ message: 'Down for maintenance',
+ retryAfter: '120',
+ },
+ {
+ path: '/loaders/validated',
+ status: 503,
+ heading: 'Status set in a validated loader',
+ message: 'Down for maintenance',
+ retryAfter: null,
+ },
+ {
+ path: '/loaders/deferred',
+ status: 429,
+ heading: 'Status set before deferred streaming',
+ message: 'Too many requests',
+ retryAfter: '120',
+ },
+ {
+ path: '/middleware/application',
+ status: 503,
+ heading: 'Status set in application middleware',
+ message: 'Down for maintenance',
+ retryAfter: '120',
+ },
+ {
+ path: '/middleware/handler',
+ status: 403,
+ heading: 'Status set in handler middleware',
+ message: 'Your account is banned',
+ retryAfter: null,
+ },
+ {
+ path: '/loaders/unexpected',
+ status: 500,
+ heading: 'Root error boundary',
+ message: 'Internal Server Error',
+ retryAfter: null,
+ },
+ {
+ path: '/loaders/not-found',
+ status: 404,
+ heading: 'Not-found boundary',
+ message: 'This page does not exist',
+ retryAfter: null,
+ },
+ ...(
+ [
+ { status: 401, message: 'Please sign in' },
+ { status: 403, message: 'Your account is banned' },
+ { status: 410, message: 'This page is permanently gone' },
+ { status: 429, message: 'Too many requests' },
+ { status: 503, message: 'Down for maintenance' },
+ ] as const
+ ).map(({ status, message }) => ({
+ path: `/loaders/errors/${status}`,
+ status,
+ heading: 'Nested error boundary',
+ message,
+ retryAfter: null,
+ })),
+ ...(
+ [
+ { status: 403, message: 'Your account is banned' },
+ { status: 503, message: 'Down for maintenance' },
+ ] as const
+ ).map(({ status, message }) => ({
+ path: `/middleware/errors/${status}`,
+ status,
+ heading: 'Root error boundary',
+ message,
+ retryAfter: null,
+ })),
+] as const;
+
+type PageData = {
+ component: string;
+ props: Record;
+ url: string;
+ deferredKeys?: Record;
+};
+
+describe.each(['dev', 'production'] as const)('HTTP errors playground (%s)', (mode) => {
+ let playground: IsolatedPlayground;
+ let server: StartedServer;
+ let browser: Browser;
+ let base: string;
+
+ function expectedMessage(example: (typeof renderedExamples)[number]) {
+ return mode === 'dev' && example.path === '/loaders/unexpected'
+ ? 'Unexpected loader failure'
+ : example.message;
+ }
+
+ beforeAll(async () => {
+ playground = createIsolatedPlayground(repository, 'playground/http-errors');
+ if (mode === 'production') {
+ const build = spawn.sync('vp', ['build'], {
+ cwd: playground.root,
+ encoding: 'utf8',
+ timeout: 60_000,
+ maxBuffer: 10 * 1024 * 1024,
+ env: { ...process.env, NODE_ENV: 'production' },
+ });
+ expect(build.status, `${build.stdout ?? ''}${build.stderr ?? ''}`).toBe(0);
+ }
+ server = await startServer(
+ (port) =>
+ mode === 'dev'
+ ? spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
+ cwd: playground.root,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ : spawn(process.execPath, ['dist/ssr/index.js'], {
+ cwd: playground.root,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, PORT: String(port), NODE_ENV: 'production' },
+ }),
+ 30_000,
+ { expectBody: 'HTTP Errors Example' },
+ );
+ base = server.base;
+ browser = await launchPagesBrowser();
+ });
+
+ afterAll(async () => {
+ await browser?.close();
+ if (server) {
+ await killChild(server.child);
+ }
+ playground?.cleanup();
+ });
+
+ for (const representation of ['HTML', 'Pages'] as const) {
+ it.each(renderedExamples)(
+ `${representation}: $path renders $heading with HTTP $status`,
+ async (example) => {
+ const pages = representation === 'Pages';
+ const response = await fetch(base + example.path, { headers: pages ? pagesHeaders : {} });
+ expect(response.status).toBe(example.status);
+ expect(response.headers.get('Retry-After')).toBe(example.retryAfter);
+ const body = await response.text();
+ if (!pages) {
+ expect(response.headers.get('content-type')).toContain('text/html');
+ expect(body).toContain(example.heading);
+ expect(body).toContain(expectedMessage(example));
+ expect(body).toContain('aria-label="Example sections"');
+ expect(body).toContain('example-page-loaded-toast');
+ if (example.path === '/loaders/deferred') {
+ expect(body).toContain('Waiting one second for deferred props');
+ expect(body).toContain('window.__resolveDeferred("details"');
+ }
+ } else {
+ expect(response.headers.get('X-VoidPages')).toBe('true');
+ const lines = body.trim().split('\n');
+ const data = deserializePageData(lines[0]);
+ expect(data.url).toBe(example.path);
+ if (example.path === '/loaders/deferred') {
+ expect(response.headers.get('content-type')).toContain('application/x-ndjson');
+ expect(data.deferredKeys).toEqual({ details: 'details' });
+ expect(lines).toHaveLength(2);
+ expect(deserializePageData(lines[1])).toMatchObject({
+ __deferred: 'details',
+ data: example.message,
+ });
+ } else {
+ expect(response.headers.get('content-type')).toContain('application/json');
+ expect(data.props).toMatchObject(
+ example.path.includes('/errors/') || example.status === 404 || example.status === 500
+ ? { error: { status: example.status, message: expectedMessage(example) } }
+ : { status: example.status, message: example.message },
+ );
+ }
+ }
+ if (example.path.startsWith('/middleware/errors/')) {
+ expect(server.output()).not.toContain('Middleware-error page loader ran');
+ }
+ },
+ );
+ }
+
+ describe('browser navigation', () => {
+ let page: Page;
+
+ beforeEach(async () => {
+ page = await browser.newPage();
+ page.setDefaultTimeout(10_000);
+ });
+ afterEach(async () => {
+ await page?.close();
+ });
+
+ async function openHome() {
+ await page.goto(base);
+ // The layout effect is also the example's observable hydration signal.
+ await page.waitForSelector('.example-page-loaded-toast:popover-open');
+ await page.evaluate(() => Reflect.set(globalThis, '__HTTP_ERRORS_DOCUMENT__', true));
+ await page
+ .locator('header')
+ .evaluate((element) => element.setAttribute('data-retained', 'yes'));
+ }
+
+ for (const navigation of ['Document load', 'Client navigation'] as const) {
+ it.each(renderedExamples)(
+ `${navigation}: $path renders $heading with HTTP $status`,
+ async (example) => {
+ await openHome();
+ const responsePromise = page.waitForResponse(
+ (response) =>
+ response.url() === base + example.path &&
+ (navigation === 'Document load'
+ ? response.request().isNavigationRequest()
+ : response.request().headers()['x-voidpages'] === 'true'),
+ );
+ await page
+ .locator(`a[href="${example.path}"]`)
+ .getByText(navigation, { exact: true })
+ .click();
+ const response = await responsePromise;
+ expect(response.status()).toBe(example.status);
+ expect(response.headers()['retry-after'] ?? null).toBe(example.retryAfter);
+ if (navigation === 'Document load') {
+ expect(response.headers()['content-type']).toContain('text/html');
+ await page.waitForSelector('.example-page-loaded-toast:popover-open');
+ } else {
+ expect(response.headers()['x-voidpages']).toBe('true');
+ }
+ await expect.poll(() => page.locator('main h1').textContent()).toBe(example.heading);
+ await expect
+ .poll(() => page.locator('main').textContent(), { timeout: 10_000 })
+ .toContain(expectedMessage(example));
+ expect(await page.locator('header').count()).toBe(1);
+ if (example.path.startsWith('/loaders/errors/')) {
+ expect(
+ await page
+ .getByText('Nested layout for HTTP exception examples.', { exact: true })
+ .count(),
+ ).toBe(1);
+ expect(
+ await page
+ .getByRole('heading', { name: 'Loader exception example', exact: true })
+ .count(),
+ ).toBe(0);
+ }
+ if (example.path.startsWith('/middleware/errors/')) {
+ expect(server.output()).not.toContain('Middleware-error page loader ran');
+ expect(
+ await page
+ .getByRole('heading', { name: 'Middleware exception example', exact: true })
+ .count(),
+ ).toBe(0);
+ }
+ const retained = navigation === 'Client navigation';
+ expect(
+ await page.evaluate(() => Reflect.get(globalThis, '__HTTP_ERRORS_DOCUMENT__')),
+ ).toBe(retained ? true : undefined);
+ expect(await page.locator('header').getAttribute('data-retained')).toBe(
+ retained ? 'yes' : null,
+ );
+ },
+ );
+ }
+
+ it('offers document and client navigation for every example', async () => {
+ await openHome();
+ const paths = renderedExamples.map(({ path }) => path).sort();
+ for (const name of ['Document load', 'Client navigation']) {
+ const links = await page.getByRole('link', { name, exact: true }).all();
+ const targets = await Promise.all(links.map((link) => link.getAttribute('href')));
+ expect(targets.sort()).toEqual(paths);
+ }
+ });
+
+ it.each(['hover', 'focus'] as const)(
+ 'prefetches complete deferred props on %s without navigating',
+ async (interaction) => {
+ await openHome();
+ const responsePromise = page.waitForResponse(
+ (response) =>
+ response.url() === base + '/loaders/deferred' &&
+ response.request().headers().purpose === 'prefetch',
+ );
+ const link = page.getByRole('link', { name: 'Hover or focus to prefetch deferred data' });
+ await link[interaction]();
+ const response = await responsePromise;
+ expect(response.status()).toBe(429);
+ expect(response.headers()['retry-after']).toBe('120');
+ expect(response.headers()['content-type']).toContain('application/json');
+ expect(response.request().headers()['x-voidpages-version']).toBe(PAGES_PROTOCOL_VERSION);
+ expect(deserializePageData(await response.text())).toMatchObject({
+ props: { details: 'Too many requests' },
+ });
+ expect(page.url()).toBe(base + '/');
+ expect(await page.locator('header').getAttribute('data-retained')).toBe('yes');
+ },
+ );
+
+ it('shows the document-load toast once and does not reopen it on client navigation', async () => {
+ await openHome();
+ await page.waitForSelector('.example-page-loaded-toast:popover-open', { state: 'hidden' });
+ await page
+ .locator('a[href="/loaders/status/403"]')
+ .getByText('Client navigation', { exact: true })
+ .click();
+ await expect.poll(() => page.locator('main h1').textContent()).toBe('Status set in a loader');
+ expect(await page.locator('.example-page-loaded-toast:popover-open').count()).toBe(0);
+ expect(await page.locator('header').getAttribute('data-retained')).toBe('yes');
+ await page.getByRole('link', { name: 'HTTP Errors Example', exact: true }).click();
+ await expect.poll(() => page.locator('main h1').textContent()).toBe('HTTP Errors Example');
+ expect(await page.locator('.example-page-loaded-toast:popover-open').count()).toBe(0);
+ expect(await page.title()).toBe('HTTP Errors Example');
+ });
+ });
+});
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 =
' Home Returned Thrown Unexpected Crash Nested Deep Layout Client only ';
let rootLayout = component(nav, '', true);
@@ -119,6 +134,16 @@ describe.each(frameworks)('Pages errors with %s', (framework) => {
write(`pages/_error.${extension}`, component('Error page {{error.message}}
'));
}
write(`pages/items/[id].${extension}`, component('Item '));
+ write(`pages/status/[code].${extension}`, component('Status page '));
+ write(
+ 'pages/status/[code].server.ts',
+ `import { defineHandler } from 'void/handler';
+export const loader = defineHandler(c => {
+ c.status(c.req.param('code') === '403' ? 403 : 503);
+ c.header('Retry-After', '120');
+ return {};
+});`,
+ );
for (const name of ['falsy', 'island-falsy.island']) {
write(`pages/${name}.${extension}`, component('Broken view ', 'throw undefined;'));
}
@@ -295,6 +320,46 @@ describe.each(frameworks)('Pages errors with %s', (framework) => {
expect(await response.text()).toContain('Missing island page');
});
+ it.each(
+ ([403, 503] as const).flatMap((status) =>
+ (['HTML', 'Pages JSON'] as const).map((format) => ({ status, format })),
+ ),
+ )(
+ 'renders middleware errors through the root page boundary with HTTP $status ($format)',
+ async ({ status, format }) => {
+ const message = status === 403 ? 'Your account is banned' : 'Down for maintenance';
+ // This route throws a different error if its loader is reached.
+ const response = await fetch(base + '/items/unexpected', {
+ headers: {
+ 'X-Test-Error-Status': String(status),
+ ...(format === 'Pages JSON'
+ ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION }
+ : { Accept: 'text/html' }),
+ },
+ });
+ const body = await response.text();
+ expect(response.status).toBe(status);
+ expect(body).not.toContain('SECRET_');
+ if (format === 'Pages JSON') {
+ expect(response.headers.get('X-VoidPages')).toBe('true');
+ expect(deserializePageData(body)).toMatchObject({
+ props: { error: { status, message } },
+ shared: { viewer: 'alice' },
+ url: '/items/unexpected',
+ });
+ } else {
+ expect(response.headers.get('Content-Type')).toContain('text/html');
+ expect(body).toContain('Error page');
+ expect(body).toContain(message);
+ expect(body).toContain('data-layout="root"');
+ expect(body).toContain('stylesheet');
+ }
+ expect(response.headers.get('Retry-After')).toBe('120');
+ expect(response.headers.get('X-Security')).toBe('retained');
+ expect(response.headers.get('Cache-Control')).toBe('no-store');
+ },
+ );
+
it('returns status-correct documents and marked error payloads without exposing secrets', async () => {
for (const [path, status] of [
['/unknown', 404],
@@ -344,6 +409,39 @@ describe.each(frameworks)('Pages errors with %s', (framework) => {
expect(response.status).toBe(404);
expect(await response.text()).toBe('API missing');
});
+ it('renders application-defined error statuses on document loads and client visits', async () => {
+ const page = await browser.newPage();
+ page.setDefaultTimeout(8_000);
+ try {
+ for (const status of [403, 503]) {
+ const path = `/status/${status}`;
+ const documentResponse = await fetch(base + path);
+ expect(documentResponse.status).toBe(status);
+ expect(documentResponse.headers.get('Retry-After')).toBe('120');
+ expect(await documentResponse.text()).toContain('Status page');
+
+ await page.goto(base);
+ await page.waitForSelector('#app[data-hydrated="true"]');
+ await page.evaluate(() =>
+ document.querySelector('nav')!.setAttribute('data-retained', 'yes'),
+ );
+ const navigation = page.waitForResponse(
+ (response) =>
+ response.url() === base + path &&
+ response.request().headers()['x-voidpages'] === 'true',
+ );
+ await page.evaluate(`window.testRouter.visit(${JSON.stringify(path)})`);
+ const response = await navigation;
+ expect(response.status()).toBe(status);
+ expect(response.headers()['x-voidpages']).toBe('true');
+ await expect.poll(() => page.locator('h1').textContent()).toBe('Status page');
+ expect(await page.locator('nav').getAttribute('data-retained')).toBe('yes');
+ }
+ } finally {
+ await page.close();
+ }
+ });
+
it('keeps the app and outer layout mounted across 404s and client errors', async () => {
const page = await browser.newPage();
const errors: Array = [];
diff --git a/packages/void/test/unit/page-boundaries.test.ts b/packages/void/test/unit/page-boundaries.test.ts
index 3609bad53..28b549767 100644
--- a/packages/void/test/unit/page-boundaries.test.ts
+++ b/packages/void/test/unit/page-boundaries.test.ts
@@ -167,6 +167,106 @@ describe('boundary render plans', () => {
});
describe('Pages loader boundary protocol', () => {
+ describe.each(['HTML', 'Pages JSON'] as const)(
+ 'HTTP exception recovery through page boundaries (%s)',
+ (format) => {
+ it.each([
+ [401, 'Please sign in'],
+ [403, 'Your account is banned'],
+ [410, 'This item was deleted'],
+ [429, 'Too many requests'],
+ [503, 'Down for maintenance'],
+ ] as const)('renders HTTP %s through the nearest error boundary', async (status, message) => {
+ const app = new Hono();
+ app.onError(createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }).onError);
+ let loaderCalls = 0;
+ app.get(
+ '/items/1',
+ createPageGetHandler({
+ componentId: 'items/item',
+ load: async () => ({
+ loader: defineHandler((c) => {
+ loaderCalls++;
+ c.header('Retry-After', '120');
+ throw new HTTPException(status, { message });
+ }),
+ }),
+ render: async (page) =>
+ `${JSON.stringify(page.props)} `,
+ assets: async () => ({ css: '', body: '', preloads: '' }),
+ headConfig: null,
+ renderOptions: {
+ boundaries: { notFound: 'not-found-plan', errors: ['items-error', 'root-error'] },
+ },
+ }),
+ );
+ const response = await app.request('https://app.test/items/1', {
+ headers:
+ format === 'Pages JSON'
+ ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION }
+ : { Accept: 'text/html' },
+ });
+ const body = await response.text();
+ expect(loaderCalls).toBe(1);
+ expect(response.status).toBe(status);
+ if (format === 'Pages JSON') {
+ expect(response.headers.get('X-VoidPages')).toBe('true');
+ expect(deserializePageData(body)).toMatchObject({
+ component: 'items-error',
+ props: { error: { status, message } },
+ url: '/items/1',
+ });
+ } else {
+ expect(response.headers.get('Content-Type')).toContain('text/html');
+ expect(body).toContain('data-component="items-error"');
+ expect(body).toContain(JSON.stringify({ error: { status, message } }));
+ }
+ expect(response.headers.get('Retry-After')).toBe('120');
+ expect(response.headers.get('Cache-Control')).toBe('no-store');
+ });
+
+ it.each([302, 503] as const)(
+ 'passes through HTTP %s with an explicit response',
+ async (status) => {
+ const app = new Hono();
+ app.onError(createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }).onError);
+ app.get(
+ '/',
+ createPageGetHandler({
+ componentId: 'index',
+ load: async () => ({
+ loader: () => {
+ throw new HTTPException(status, {
+ res: new Response('Explicit response', {
+ status,
+ headers: status === 302 ? { Location: '/login' } : { 'Retry-After': '120' },
+ }),
+ });
+ },
+ }),
+ render: async () => 'Error boundary ',
+ assets: async () => ({ css: '', body: '', preloads: '' }),
+ headConfig: null,
+ renderOptions: { boundaries: { errors: ['root-error'] } },
+ }),
+ );
+ const response = await app.request('https://app.test/', {
+ headers:
+ format === 'Pages JSON'
+ ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION }
+ : { Accept: 'text/html' },
+ });
+ expect(response.status).toBe(status);
+ expect(response.headers.has('X-VoidPages')).toBe(false);
+ expect(response.headers.get(status === 302 ? 'Location' : 'Retry-After')).toBe(
+ status === 302 ? '/login' : '120',
+ );
+ expect(await response.text()).toBe('Explicit response');
+ },
+ );
+ },
+ );
+
it.each(['returned', 'thrown', 'empty-message', 'unexpected'])(
'renders %s failures as marked, uncached Pages data',
async (kind) => {
diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts
index 9c3c2c169..457b1042f 100644
--- a/packages/void/test/unit/routing-runtime.test.ts
+++ b/packages/void/test/unit/routing-runtime.test.ts
@@ -1702,6 +1702,112 @@ describe('page loaders and actions composed with defineHandler middleware', () =
expect(await html.text()).toContain('{"notes":["a"]}');
});
+ describe.each(['HTML', 'Pages JSON'] as const)(
+ 'Application-defined page status codes (%s)',
+ (format) => {
+ it.each(
+ ([403, 503] as const).flatMap((status) =>
+ (['loader', 'validated loader', 'app middleware', 'handler middleware'] as const).map(
+ (source) => ({
+ status,
+ source,
+ }),
+ ),
+ ),
+ )('renders the page with HTTP $status set by $source', async ({ status, source }) => {
+ const message = status === 403 ? 'Your account is banned' : 'Down for maintenance';
+ const setStatus = defineMiddleware(async (c, next) => {
+ c.status(status);
+ c.header('Retry-After', '120');
+ await next();
+ });
+ const loader = vi.fn((c: CloudContext) => {
+ if (source === 'loader' || source === 'validated loader') {
+ c.status(status);
+ c.header('Retry-After', '120');
+ }
+ return { message };
+ });
+ const app = createPageApp(
+ {
+ loader:
+ source === 'handler middleware'
+ ? defineHandler(setStatus, loader)
+ : source === 'validated loader'
+ ? defineHandler.withValidator({})(loader)
+ : defineHandler(loader),
+ },
+ async (c, next) => {
+ c.header('Set-Cookie', 'session=renewed; HttpOnly', { append: true });
+ c.header('Set-Cookie', 'locale=en', { append: true });
+ if (source === 'app middleware') {
+ return setStatus(c, next);
+ }
+ await next();
+ },
+ );
+ const response = await get(app, format === 'HTML' ? {} : undefined);
+ const body = await response.text();
+
+ expect(loader).toHaveBeenCalledTimes(1);
+ expect(response.headers.get('Retry-After')).toBe('120');
+ expect(response.headers.getSetCookie()).toEqual(['session=renewed; HttpOnly', 'locale=en']);
+ if (format === 'Pages JSON') {
+ expect(response.headers.get('X-VoidPages')).toBe('true');
+ expect(decodePageData(JSON.parse(body))).toMatchObject({
+ component: 'page',
+ props: { message },
+ });
+ } else {
+ expect(response.headers.get('Content-Type')).toContain('text/html');
+ expect(body).toContain(message);
+ }
+ expect(response.status).toBe(status);
+ });
+
+ it('preserves the loader status while streaming deferred props', async () => {
+ const app = createPageApp({
+ loader: defineHandler((c) => {
+ c.status(429);
+ return { slow: defer(async () => 'Try again later') };
+ }),
+ });
+ const response = await get(app, format === 'HTML' ? {} : undefined);
+ const body = await response.text();
+ expect(response.status).toBe(429);
+ if (format === 'Pages JSON') {
+ expect(response.headers.get('Content-Type')).toBe('application/x-ndjson');
+ expect(response.headers.get('X-VoidPages')).toBe('true');
+ expect(body).toContain(
+ serializePageData({ __deferred: 'slow', data: 'Try again later' }),
+ );
+ } else {
+ expect(body).toContain('__resolveDeferred');
+ expect(body).toContain('Try again later');
+ }
+ });
+ },
+ );
+
+ it('preserves the loader status when prefetching deferred page data', async () => {
+ const app = createPageApp({
+ loader: defineHandler((c) => {
+ c.status(503);
+ return { slow: defer(async () => 'Try again later') };
+ }),
+ });
+ const response = await get(app, {
+ 'X-VoidPages': 'true',
+ 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION,
+ Purpose: 'prefetch',
+ });
+ expect(response.status).toBe(503);
+ expect(response.headers.get('Content-Type')).toBe('application/json');
+ expect(decodePageData(await response.json())).toMatchObject({
+ props: { slow: 'Try again later' },
+ });
+ });
+
it('re-renders the page after an action with middleware, or shows its field errors', async () => {
const action = (message?: string) =>
defineHandler(
diff --git a/playground/http-errors/README.md b/playground/http-errors/README.md
new file mode 100644
index 000000000..5c696e622
--- /dev/null
+++ b/playground/http-errors/README.md
@@ -0,0 +1,18 @@
+# HTTP errors
+
+Explore HTTP statuses, error boundaries, and navigation behavior through React Pages API examples.
+
+From the repository root:
+
+```sh
+vp run --filter ./playground/http-errors dev
+```
+
+Run the HTTP and browser scenarios from the repository root:
+
+```sh
+vp test run packages/void/test/integration/http-errors-playground.test.ts
+```
+
+The suite runs an isolated copy in development and production, covering response statuses,
+error boundaries, document loads, client navigation, deferred streaming, and prefetching.
diff --git a/playground/http-errors/middleware/http-errors.ts b/playground/http-errors/middleware/http-errors.ts
new file mode 100644
index 000000000..ae116daf3
--- /dev/null
+++ b/playground/http-errors/middleware/http-errors.ts
@@ -0,0 +1,20 @@
+import { HTTPException } from 'hono/http-exception';
+import { defineMiddleware } from 'void/handler';
+import { statusMessages } from '../src/utils.ts';
+
+export default defineMiddleware(async (c, next) => {
+ if (c.req.path === '/middleware/application') {
+ c.status(503);
+ c.header('Retry-After', '120');
+ }
+
+ if (c.req.path === '/middleware/errors/403') {
+ throw new HTTPException(403, { message: statusMessages[403] });
+ }
+
+ if (c.req.path === '/middleware/errors/503') {
+ throw new HTTPException(503, { message: statusMessages[503] });
+ }
+
+ await next();
+});
diff --git a/playground/http-errors/package.json b/playground/http-errors/package.json
new file mode 100644
index 000000000..a0471f52f
--- /dev/null
+++ b/playground/http-errors/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "playground-http-errors",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vp build",
+ "dev": "vp dev",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hono/node-server": "catalog:",
+ "hono": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@void/react": "workspace:*",
+ "typescript": "catalog:",
+ "vite": "catalog:",
+ "vite-plus": "catalog:",
+ "void": "workspace:*"
+ }
+}
diff --git a/playground/http-errors/pages/_error.tsx b/playground/http-errors/pages/_error.tsx
new file mode 100644
index 000000000..8da2a97d3
--- /dev/null
+++ b/playground/http-errors/pages/_error.tsx
@@ -0,0 +1,12 @@
+import type { ErrorPageProps } from '@void/react';
+
+export default function RootError({ error }: ErrorPageProps) {
+ return (
+
+ Root error boundary
+
+ {error.status}: {error.message}
+
+
+ );
+}
diff --git a/playground/http-errors/pages/_not-found.tsx b/playground/http-errors/pages/_not-found.tsx
new file mode 100644
index 000000000..6b82e4125
--- /dev/null
+++ b/playground/http-errors/pages/_not-found.tsx
@@ -0,0 +1,12 @@
+import type { ErrorPageProps } from '@void/react';
+
+export default function NotFound({ error }: ErrorPageProps) {
+ return (
+
+ Not-found boundary
+
+ {error.status}: {error.message}
+
+
+ );
+}
diff --git a/playground/http-errors/pages/index.tsx b/playground/http-errors/pages/index.tsx
new file mode 100644
index 000000000..a078787de
--- /dev/null
+++ b/playground/http-errors/pages/index.tsx
@@ -0,0 +1,224 @@
+import { Link } from '@void/react';
+import type { ReactNode } from 'react';
+import { SourceLink } from '../src/source-link.tsx';
+
+const loaderExamples = [
+ {
+ path: '/loaders/status/403',
+ title: 'Loader sets 403',
+ file: 'pages/loaders/status/[status].server.ts',
+ code: 'c.status(403);\nreturn { message: messages[403] };',
+ },
+ {
+ path: '/loaders/status/503',
+ title: 'Loader sets 503',
+ file: 'pages/loaders/status/[status].server.ts',
+ code: "c.status(503);\nc.header('Retry-After', '120');\nreturn { message: messages[503] };",
+ },
+ {
+ path: '/loaders/validated',
+ title: 'Validated loader sets 503',
+ file: 'pages/loaders/validated.server.ts',
+ code: 'export const loader = defineHandler.withValidator({})((c) => {\n c.status(503);\n return { message: messages[503] };\n});',
+ },
+ {
+ path: '/loaders/deferred',
+ title: 'Loader sets 429 before deferred streaming',
+ file: 'pages/loaders/deferred.server.ts',
+ code: 'c.status(429);\nreturn { details: defer(async () => {\n await new Promise(resolve => setTimeout(resolve, 1000));\n return messages[429];\n}) };',
+ },
+] as const;
+
+const middlewareExamples = [
+ {
+ path: '/middleware/application',
+ title: 'Application middleware sets 503',
+ file: 'middleware/http-errors.ts',
+ code: '// Inside application middleware, for this path:\nc.status(503);\nawait next();',
+ },
+ {
+ path: '/middleware/handler',
+ title: 'Handler middleware sets 403',
+ file: 'pages/middleware/handler.server.ts',
+ code: 'const guard = defineMiddleware(async (c, next) => {\n c.status(403);\n await next();\n});\nexport const loader = defineHandler(guard, () => ({ message: messages[403] }));',
+ },
+] as const;
+
+function Visits({ path }: { path: string }) {
+ return (
+
+ Document load | Client navigation
+
+ );
+}
+
+function Example({
+ title,
+ file,
+ code,
+ expected,
+ children,
+}: {
+ title: string;
+ file: string;
+ code?: string;
+ expected: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+ {title}
+
+
+
+ {code && {code} }
+ Expected
+ {expected}
+ {children}
+
+ );
+}
+
+export default function Home() {
+ return (
+ <>
+ HTTP Errors Example
+
+ Explore how loader and middleware APIs affect response statuses, page content, and layouts.
+ Compare full document loads, client navigation, deferred streaming, and prefetching.
+
+
+ Related: {' '}
+ Issue #784 |{' '}
+ PR #788
+
+
+
+
+ Loaders
+
+ Use c.status() to choose the HTTP status while returning props for the requested page. An
+ error status does not select _error or stop rendering. Both document loads and client
+ navigation display the page with its props and layouts.
+
+
+ {loaderExamples.map(({ path, title, file, code }) => (
+
+ The response uses the requested status and renders the page with its props and
+ layouts. Client navigation keeps the app’s layout.
+
+ }
+ >
+
+
+ ))}
+
+
+ HTTP 429, Purpose: prefetch, and a complete JSON payload after the one-second
+ deferred task. Prefetching waits for deferred props rather than streaming them.
+
+ }
+ >
+
+ Hover or focus this link without clicking, then inspect the prefetch request in the
+ browser’s Network panel.
+
+
+ Hover or focus to prefetch deferred data
+
+
+
+ {[401, 403, 410, 429, 503].map((status) => (
+
+ Stops the loader and renders the nested _error boundary with the thrown HTTP
+ status and message. The root and nested layouts remain visible, and client
+ navigation keeps the app’s layout.
+
+ }
+ >
+
+
+ ))}
+
+
HTTP 500 with the root error boundary inside the app’s layout.}
+ >
+
+
+
HTTP 404 with the not-found boundary inside the app’s layout.}
+ >
+
+
+
+
+
+
+
+ Middleware
+
+ Application middleware runs before the route handler. Handler middleware runs as part of
+ defineHandler(). Both can set a status and call await next() to render the page normally.
+
+
+ {middlewareExamples.map(({ path, title, file, code }) => (
+
+ The loader runs and the response uses the middleware’s status, rendering the page
+ with its props and layouts.
+
+ }
+ >
+
+
+ ))}
+
+ {[403, 503].map((status) => (
+
+ Stops the request before its loader runs and renders the root _error boundary with
+ the thrown HTTP status and message. The root layout remains visible, and client
+ navigation keeps it mounted.
+
+ }
+ >
+
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/playground/http-errors/pages/layout.tsx b/playground/http-errors/pages/layout.tsx
new file mode 100644
index 000000000..d1f63034c
--- /dev/null
+++ b/playground/http-errors/pages/layout.tsx
@@ -0,0 +1,35 @@
+import { Link } from '@void/react';
+import { useEffect, useRef, type ReactNode } from 'react';
+import './styles.css';
+
+export default function Layout({ children }: { children: ReactNode }) {
+ const toastRef = useRef(null);
+
+ useEffect(() => {
+ const toast = toastRef.current!;
+ toast.showPopover();
+ const timeout = window.setTimeout(() => toast.hidePopover(), 2500);
+ return () => {
+ window.clearTimeout(timeout);
+ toast.hidePopover();
+ };
+ }, []);
+
+ return (
+
+
+ Page loaded
+
+
+
+
+ HTTP Errors Example
+ {' '}
+ | Loaders | Middleware
+
+
+
+
{children}
+
+ );
+}
diff --git a/playground/http-errors/pages/loaders/deferred.server.ts b/playground/http-errors/pages/loaders/deferred.server.ts
new file mode 100644
index 000000000..b3bb57a65
--- /dev/null
+++ b/playground/http-errors/pages/loaders/deferred.server.ts
@@ -0,0 +1,15 @@
+import { defineHandler, defer, type InferProps } from 'void/handler';
+import { statusMessages } from '../../src/utils.ts';
+
+export const loader = defineHandler((c) => {
+ c.status(429);
+ c.header('Retry-After', '120');
+ return {
+ details: defer(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ return statusMessages[429];
+ }),
+ };
+});
+
+export type Props = InferProps;
diff --git a/playground/http-errors/pages/loaders/deferred.tsx b/playground/http-errors/pages/loaders/deferred.tsx
new file mode 100644
index 000000000..cb006c760
--- /dev/null
+++ b/playground/http-errors/pages/loaders/deferred.tsx
@@ -0,0 +1,18 @@
+import { Suspense, use } from 'react';
+import type { Props } from './deferred.server.ts';
+
+function Details({ details }: Props) {
+ return {use(details)}
;
+}
+
+export default function DeferredPage(props: Props) {
+ return (
+
+ Status set before deferred streaming
+ Expected HTTP 429 for both the HTML document and the client navigation stream.
+ Waiting one second for deferred props…
}>
+
+
+
+ );
+}
diff --git a/playground/http-errors/pages/loaders/errors/[status].server.ts b/playground/http-errors/pages/loaders/errors/[status].server.ts
new file mode 100644
index 000000000..e5c572323
--- /dev/null
+++ b/playground/http-errors/pages/loaders/errors/[status].server.ts
@@ -0,0 +1,9 @@
+import { HTTPException } from 'hono/http-exception';
+import { defineHandler } from 'void/handler';
+import { statusMessages, parseStatus } from '../../../src/utils.ts';
+
+export const loader = defineHandler((c) => {
+ const status = parseStatus(c);
+
+ throw new HTTPException(status, { message: statusMessages[status] });
+});
diff --git a/playground/http-errors/pages/loaders/errors/[status].tsx b/playground/http-errors/pages/loaders/errors/[status].tsx
new file mode 100644
index 000000000..f220ecaaf
--- /dev/null
+++ b/playground/http-errors/pages/loaders/errors/[status].tsx
@@ -0,0 +1,3 @@
+export default function LoaderPage() {
+ return Loader exception example ;
+}
diff --git a/playground/http-errors/pages/loaders/errors/_error.tsx b/playground/http-errors/pages/loaders/errors/_error.tsx
new file mode 100644
index 000000000..a7bcf3f44
--- /dev/null
+++ b/playground/http-errors/pages/loaders/errors/_error.tsx
@@ -0,0 +1,12 @@
+import type { ErrorPageProps } from '@void/react';
+
+export default function NestedError({ error }: ErrorPageProps) {
+ return (
+
+ Nested error boundary
+
+ {error.status}: {error.message}
+
+
+ );
+}
diff --git a/playground/http-errors/pages/loaders/errors/layout.tsx b/playground/http-errors/pages/loaders/errors/layout.tsx
new file mode 100644
index 000000000..50363c31f
--- /dev/null
+++ b/playground/http-errors/pages/loaders/errors/layout.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from 'react';
+
+export default function LoaderErrorLayout({ children }: { children: ReactNode }) {
+ return (
+
+ Nested layout for HTTP exception examples.
+ {children}
+
+ );
+}
diff --git a/playground/http-errors/pages/loaders/not-found.server.ts b/playground/http-errors/pages/loaders/not-found.server.ts
new file mode 100644
index 000000000..ea0a6be9a
--- /dev/null
+++ b/playground/http-errors/pages/loaders/not-found.server.ts
@@ -0,0 +1,7 @@
+import { HTTPException } from 'hono/http-exception';
+import { defineHandler } from 'void/handler';
+import { statusMessages } from '../../src/utils.ts';
+
+export const loader = defineHandler(() => {
+ throw new HTTPException(404, { message: statusMessages[404] });
+});
diff --git a/playground/http-errors/pages/loaders/not-found.tsx b/playground/http-errors/pages/loaders/not-found.tsx
new file mode 100644
index 000000000..f962c7569
--- /dev/null
+++ b/playground/http-errors/pages/loaders/not-found.tsx
@@ -0,0 +1,3 @@
+export default function MissingPage() {
+ return Not-found example ;
+}
diff --git a/playground/http-errors/pages/loaders/status/[status].server.ts b/playground/http-errors/pages/loaders/status/[status].server.ts
new file mode 100644
index 000000000..a44e2f7a3
--- /dev/null
+++ b/playground/http-errors/pages/loaders/status/[status].server.ts
@@ -0,0 +1,13 @@
+import { defineHandler } from 'void/handler';
+import { statusMessages, parseStatus } from '../../../src/utils.ts';
+
+export const loader = defineHandler((c) => {
+ const status = parseStatus(c);
+ c.status(status);
+ c.header('Retry-After', '120');
+ return {
+ title: 'Status set in a loader',
+ status,
+ message: statusMessages[status],
+ };
+});
diff --git a/playground/http-errors/pages/loaders/status/[status].tsx b/playground/http-errors/pages/loaders/status/[status].tsx
new file mode 100644
index 000000000..d39705d6f
--- /dev/null
+++ b/playground/http-errors/pages/loaders/status/[status].tsx
@@ -0,0 +1 @@
+export { default } from '../../../src/status-page.tsx';
diff --git a/playground/http-errors/pages/loaders/unexpected.server.ts b/playground/http-errors/pages/loaders/unexpected.server.ts
new file mode 100644
index 000000000..9ac88ecfb
--- /dev/null
+++ b/playground/http-errors/pages/loaders/unexpected.server.ts
@@ -0,0 +1,6 @@
+import { defineHandler } from 'void/handler';
+import { statusMessages } from '../../src/utils.ts';
+
+export const loader = defineHandler(() => {
+ throw new Error(statusMessages[500]);
+});
diff --git a/playground/http-errors/pages/loaders/unexpected.tsx b/playground/http-errors/pages/loaders/unexpected.tsx
new file mode 100644
index 000000000..ec3f357b0
--- /dev/null
+++ b/playground/http-errors/pages/loaders/unexpected.tsx
@@ -0,0 +1,3 @@
+export default function UnexpectedPage() {
+ return Unexpected error example ;
+}
diff --git a/playground/http-errors/pages/loaders/validated.server.ts b/playground/http-errors/pages/loaders/validated.server.ts
new file mode 100644
index 000000000..c697cd8ef
--- /dev/null
+++ b/playground/http-errors/pages/loaders/validated.server.ts
@@ -0,0 +1,11 @@
+import { defineHandler } from 'void/handler';
+import { statusMessages } from '../../src/utils.ts';
+
+export const loader = defineHandler.withValidator({})((c) => {
+ c.status(503);
+ return {
+ title: 'Status set in a validated loader',
+ status: 503,
+ message: statusMessages[503],
+ };
+});
diff --git a/playground/http-errors/pages/loaders/validated.tsx b/playground/http-errors/pages/loaders/validated.tsx
new file mode 100644
index 000000000..68421b3c2
--- /dev/null
+++ b/playground/http-errors/pages/loaders/validated.tsx
@@ -0,0 +1 @@
+export { default } from '../../src/status-page.tsx';
diff --git a/playground/http-errors/pages/middleware/application.server.ts b/playground/http-errors/pages/middleware/application.server.ts
new file mode 100644
index 000000000..e91875349
--- /dev/null
+++ b/playground/http-errors/pages/middleware/application.server.ts
@@ -0,0 +1,8 @@
+import { defineHandler } from 'void/handler';
+import { statusMessages } from '../../src/utils.ts';
+
+export const loader = defineHandler(() => ({
+ title: 'Status set in application middleware',
+ status: 503,
+ message: statusMessages[503],
+}));
diff --git a/playground/http-errors/pages/middleware/application.tsx b/playground/http-errors/pages/middleware/application.tsx
new file mode 100644
index 000000000..68421b3c2
--- /dev/null
+++ b/playground/http-errors/pages/middleware/application.tsx
@@ -0,0 +1 @@
+export { default } from '../../src/status-page.tsx';
diff --git a/playground/http-errors/pages/middleware/errors/[status].server.ts b/playground/http-errors/pages/middleware/errors/[status].server.ts
new file mode 100644
index 000000000..b836335b4
--- /dev/null
+++ b/playground/http-errors/pages/middleware/errors/[status].server.ts
@@ -0,0 +1,6 @@
+import { defineHandler } from 'void/handler';
+
+export const loader = defineHandler(() => {
+ console.log('Middleware-error page loader ran. It should have been blocked.');
+ return {};
+});
diff --git a/playground/http-errors/pages/middleware/errors/[status].tsx b/playground/http-errors/pages/middleware/errors/[status].tsx
new file mode 100644
index 000000000..5a5370693
--- /dev/null
+++ b/playground/http-errors/pages/middleware/errors/[status].tsx
@@ -0,0 +1,3 @@
+export default function MiddlewarePage() {
+ return Middleware exception example ;
+}
diff --git a/playground/http-errors/pages/middleware/handler.server.ts b/playground/http-errors/pages/middleware/handler.server.ts
new file mode 100644
index 000000000..be09cf87d
--- /dev/null
+++ b/playground/http-errors/pages/middleware/handler.server.ts
@@ -0,0 +1,13 @@
+import { defineHandler, defineMiddleware } from 'void/handler';
+import { statusMessages } from '../../src/utils.ts';
+
+const setStatus = defineMiddleware(async (c, next) => {
+ c.status(403);
+ await next();
+});
+
+export const loader = defineHandler(setStatus, () => ({
+ title: 'Status set in handler middleware',
+ status: 403,
+ message: statusMessages[403],
+}));
diff --git a/playground/http-errors/pages/middleware/handler.tsx b/playground/http-errors/pages/middleware/handler.tsx
new file mode 100644
index 000000000..68421b3c2
--- /dev/null
+++ b/playground/http-errors/pages/middleware/handler.tsx
@@ -0,0 +1 @@
+export { default } from '../../src/status-page.tsx';
diff --git a/playground/http-errors/pages/styles.css b/playground/http-errors/pages/styles.css
new file mode 100644
index 000000000..c87b64a18
--- /dev/null
+++ b/playground/http-errors/pages/styles.css
@@ -0,0 +1,54 @@
+.example-container {
+ box-sizing: border-box;
+ max-width: 100rem;
+ margin-inline: auto;
+ padding: 1rem;
+}
+
+.example-container p {
+ max-width: 75ch;
+}
+
+.example-page-loaded-toast {
+ position: fixed;
+ inset: 1rem auto auto 50%;
+ transform: translateX(-50%);
+ margin: 0;
+ padding: 0.75rem 1rem;
+ border: 1px solid;
+ border-radius: 0.25rem;
+ background: Canvas;
+ color: CanvasText;
+}
+
+.examples {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: start;
+ gap: 1rem 2rem;
+}
+
+.example {
+ min-width: 0;
+}
+
+@media (max-width: 72rem) {
+ .examples {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 48rem) {
+ .examples {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.example pre {
+ white-space: pre-wrap;
+}
+
+.example pre,
+.example code {
+ overflow-wrap: anywhere;
+}
diff --git a/playground/http-errors/src/editor-url.ts b/playground/http-errors/src/editor-url.ts
new file mode 100644
index 000000000..3ecc05701
--- /dev/null
+++ b/playground/http-errors/src/editor-url.ts
@@ -0,0 +1,5 @@
+export function vscodeFileUrl(root: string, file: string): string {
+ const source = new URL(file.split('/').map(encodeURIComponent).join('/'), root);
+ const host = source.host ? `//${source.host}` : '';
+ return `vscode://file${host}${source.pathname}:1:1`;
+}
diff --git a/playground/http-errors/src/source-link.tsx b/playground/http-errors/src/source-link.tsx
new file mode 100644
index 000000000..8581f3084
--- /dev/null
+++ b/playground/http-errors/src/source-link.tsx
@@ -0,0 +1,9 @@
+import { vscodeFileUrl } from './editor-url.ts';
+
+export function SourceLink({ file }: { file: string }) {
+ return (
+
+ {file}
+
+ );
+}
diff --git a/playground/http-errors/src/status-page.tsx b/playground/http-errors/src/status-page.tsx
new file mode 100644
index 000000000..d2cbd5cff
--- /dev/null
+++ b/playground/http-errors/src/status-page.tsx
@@ -0,0 +1,18 @@
+export type StatusProps = {
+ title: string;
+ status: number;
+ message: string;
+};
+
+export default function StatusPage({ title, status, message }: StatusProps) {
+ return (
+
+ {title}
+ {message}
+ Requested HTTP status: {status}. Check the actual response in the Network panel.
+
+ This is the normal page, not an error boundary. Setting c.status() does not abort rendering.
+
+
+ );
+}
diff --git a/playground/http-errors/src/utils.ts b/playground/http-errors/src/utils.ts
new file mode 100644
index 000000000..e0f3205e1
--- /dev/null
+++ b/playground/http-errors/src/utils.ts
@@ -0,0 +1,15 @@
+import type { CloudContext } from 'void/handler';
+
+export const statusMessages = {
+ 401: 'Please sign in',
+ 403: 'Your account is banned',
+ 404: 'This page does not exist',
+ 410: 'This page is permanently gone',
+ 429: 'Too many requests',
+ 500: 'Unexpected loader failure',
+ 503: 'Down for maintenance',
+} as const
+
+export function parseStatus(c: CloudContext) {
+ return parseInt(c.req.param('status')!) as keyof typeof statusMessages;
+}
diff --git a/playground/http-errors/src/vite-env.d.ts b/playground/http-errors/src/vite-env.d.ts
new file mode 100644
index 000000000..83d437882
--- /dev/null
+++ b/playground/http-errors/src/vite-env.d.ts
@@ -0,0 +1,3 @@
+///
+
+declare const __HTTP_ERRORS_SOURCE_ROOT__: string;
diff --git a/playground/http-errors/tsconfig.json b/playground/http-errors/tsconfig.json
new file mode 100644
index 000000000..18d0c6eb8
--- /dev/null
+++ b/playground/http-errors/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "noUnusedLocals": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "jsx": "react-jsx",
+ "noEmit": true,
+ "allowImportingTsExtensions": true
+ },
+ "include": ["pages", "middleware", "src", "*.config.ts"]
+}
diff --git a/playground/http-errors/vite.config.ts b/playground/http-errors/vite.config.ts
new file mode 100644
index 000000000..48c1c0d91
--- /dev/null
+++ b/playground/http-errors/vite.config.ts
@@ -0,0 +1,10 @@
+import { voidReact } from '@void/react/plugin';
+import { defineConfig } from 'vite-plus';
+import { voidPlugin } from 'void';
+
+export default defineConfig({
+ define: {
+ __HTTP_ERRORS_SOURCE_ROOT__: JSON.stringify(new URL('./', import.meta.url).href),
+ },
+ plugins: [voidPlugin(), voidReact()],
+});
diff --git a/playground/http-errors/void.config.ts b/playground/http-errors/void.config.ts
new file mode 100644
index 000000000..83f948417
--- /dev/null
+++ b/playground/http-errors/void.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'void/config';
+
+export default defineConfig({
+ target: 'node',
+ head: {
+ title: 'HTTP Errors Example',
+ htmlAttrs: { lang: 'en' },
+ meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1f44dec15..ef8a9ebc1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1351,6 +1351,43 @@ importers:
specifier: workspace:*
version: link:../../packages/void
+ playground/http-errors:
+ dependencies:
+ '@hono/node-server':
+ specifier: 'catalog:'
+ version: 2.1.6(hono@4.13.17)
+ hono:
+ specifier: 'catalog:'
+ version: 4.13.17
+ react:
+ specifier: 'catalog:'
+ version: 19.3.1
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.3.1(react@19.3.1)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.3.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.3.0
+ version: 19.3.0(@types/react@19.3.0)
+ '@void/react':
+ specifier: workspace:*
+ version: link:../../packages/react
+ typescript:
+ specifier: 'catalog:'
+ version: 7.0.2
+ vite:
+ specifier: 'catalog:'
+ version: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)'
+ vite-plus:
+ specifier: 'catalog:'
+ version: 1.6.0(@astrojs/compiler-rs@0.5.2)(@types/node@26.6.6)(@vitest/ui@5.1.0)(esbuild@0.28.2)(happy-dom@21.0.4)(jiti@2.7.0)(playwright@1.65.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)
+ void:
+ specifier: workspace:*
+ version: link:../../packages/void
+
playground/kitchen-sink:
dependencies:
hono: