Loaders and props
A loader fetches the data a page needs. Write it in a companion .server.ts file with the same defineHandler API as server routes.
On this pageDefine a loader · Use its data as typed props · Handle errors and redirects
Defining a loader
This step defines a loader. loader runs on GET requests. Its return value becomes the page's props.
import { users } from '@schema';
// pages/users/index.server.ts
import { defineHandler } from 'void';
import type { InferProps } from 'void';
import { db } from 'void/db';
export type Props = InferProps<typeof loader>;
export const loader = defineHandler(async (c) => {
return { users: await db.select().from(users) };
});InferProps infers the loader’s return type. Page props always match its data.
Using the data in page components
This step uses the data in the page. The component gets the loader's result as props. Export the inferred type from the server file to keep them in sync.
// pages/users/index.tsx
import type { Props } from './index.server';
export default function UsersPage({ users }: Props) {
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}<!-- pages/users/index.vue -->
<script setup lang="ts">
import type { Props } from './index.server';
defineProps<Props>();
</script>
<template>
<h1>Users</h1>
<ul>
<li v-for="u in users" :key="u.id">{{ u.name }}</li>
</ul>
</template><!-- pages/users/index.svelte -->
<script lang="ts">
import type { Props } from "./index.server";
let { users }: Props = $props();
</script>
<h1>Users</h1>
<ul>
{#each users as u (u.id)}
<li>{u.name}</li>
{/each}
</ul>import { For } from 'solid-js';
// pages/users/index.tsx
import type { Props } from './index.server';
export default function UsersPage(props: Props) {
return (
<>
<h1>Users</h1>
<ul>
<For each={props.users}>{(u) => <li>{u.name}</li>}</For>
</ul>
</>
);
}Deferred props
This step defers slow data with defer(). The page renders at once and gets the result when it is ready.
import { projects } from '@schema';
// pages/dashboard.server.ts
import { defineHandler, defer } from 'void';
import type { InferProps } from 'void';
import { db } from 'void/db';
export type Props = InferProps<typeof loader>;
export const loader = defineHandler(async (c) => {
const allProjects = await db.select().from(projects); // fast, returns immediately
return {
projects: allProjects,
usage: defer(async () => {
return await fetchUsageMetrics(); // slow, streams when ready
}),
};
});The page can render projects while usage is still loading. In React, read usage with use() inside Suspense. Other adapters expose it as { loading, value, error }.
Handling deferred state
In React, Deferred<T> is a promise. This step reads it with use() inside a <Suspense> boundary.
import { Suspense, use } from 'react';
import type { Props } from './dashboard.server';
function Usage({ usage }: Pick<Props, 'usage'>) {
const resolved = use(usage);
return <p>{resolved.requests} requests</p>;
}
export default function Dashboard({ projects, usage }: Props) {
return (
<div>
<h1>Projects ({projects.length})</h1>
<Suspense fallback={<p>Loading usage...</p>}>
<Usage usage={usage} />
</Suspense>
</div>
);
}Rejected deferred props throw from use(). For a custom failure state, wrap the Suspense boundary in a React error boundary. For explicit React prop types, import Deferred from @void/react. It is typed as Promise<T>.
In production, an unexpected error in a deferred prop shows as Internal Server Error. Throw an HTTPException to show your own message.
In Vue, Svelte, and Solid, Deferred<T> behaves as a discriminated union with three states:
type Deferred<T> =
| { loading: true; value: null; error: null } // pending
| { loading: false; value: T; error: null } // resolved
| { loading: false; value: null; error: Error }; // rejectedThis step checks loading first, then error. TypeScript narrows value to T in the resolved branch.
<script setup lang="ts">
import type { Props } from './dashboard.server';
defineProps<Props>();
</script>
<template>
<div>
<h1>Projects ({{ projects.length }})</h1>
<p v-if="usage.loading">Loading usage...</p>
<p v-else-if="usage.error">Failed: {{ usage.error.message }}</p>
<p v-else>{{ usage.value.requests }} requests</p>
</div>
</template><script lang="ts">
import type { Props } from "./dashboard.server";
let { projects, usage }: Props = $props();
</script>
<div>
<h1>Projects ({projects.length})</h1>
{#if usage.loading}
<p>Loading usage...</p>
{:else if usage.error}
<p>Failed: {usage.error.message}</p>
{:else}
<p>{usage.value.requests} requests</p>
{/if}
</div>import type { Props } from './dashboard.server';
export default function Dashboard(props: Props) {
return (
<div>
<h1>Projects ({props.projects.length})</h1>
{props.usage.loading ? (
<p>Loading usage...</p>
) : props.usage.error ? (
<p>Failed: {props.usage.error.message}</p>
) : (
<p>{props.usage.value.requests} requests</p>
)}
</div>
);
}Deferred props after mutations
INFO
After an action, deferred props keep their last resolved values until the next page load or client-side navigation. Other loader props refresh immediately.
Grouped deferred props
This step groups props that share one slow operation. A named group resolves them with a single call.
export const loader = defineHandler<Props>(async (c) => {
const analyticsResolver = async () => {
const data = await fetchAnalytics(); // one slow call
return { metrics: data.metrics, chart: data.chart };
};
return {
projects: await db.select().from(projects),
metrics: defer('analytics', analyticsResolver),
chart: defer('analytics', analyticsResolver),
};
});metrics and chart both come from one call to the analytics resolver. The component receives them as separate Deferred<T> props.