Loaders & Props
A loader fetches the data a page needs. Define it in a companion .server.ts file using the same defineHandler API as server routes.
Defining a Loader
loader runs on GET requests and returns an object that becomes the page's props:
// pages/users/index.server.ts
import { defineHandler } from 'void';
import type { InferProps } from 'void';
import { db } from 'void/db';
import { users } from '@schema';
export type Props = InferProps<typeof loader>;
export const loader = defineHandler(async (c) => {
return { users: await db.select().from(users) };
});InferProps extracts the return type from a defineHandler call, so you don't need to maintain a separate interface. You can also write the interface explicitly if you prefer:
export interface Props {
users: Array<{ id: number; name: string; email: string }>;
}
export const loader = defineHandler<Props>(async (c) => {
return { users: await db.select().from(users) };
});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:
// 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>// pages/users/index.tsx
import type { Props } from './index.server';
import { For } from 'solid-js';
export default function UsersPage(props: Props) {
return (
<>
<h1>Users</h1>
<ul>
<For each={props.users}>{(u) => <li>{u.name}</li>}</For>
</ul>
</>
);
}Deferred Props
Loaders sometimes need to fetch slower data such as analytics, external API responses, or AI inference. defer() lets you return a placeholder immediately so the page renders quickly, then streams the real data when it resolves:
// pages/dashboard.server.ts
import { defineHandler, defer } from 'void';
import type { InferProps } from 'void';
import { db } from 'void/db';
import { projects } from '@schema';
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 consumed as a promise. Put the deferred read under a <Suspense> boundary and call use() where the value is needed:
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(). Put a normal React error boundary around the Suspense boundary when the page should render a custom failure state. For explicit React prop annotations, import Deferred from @void/react; the adapter export is typed as Promise<T>.
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 }; // rejectedCheck 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>
);
}How Streaming Works
On the first request, React renders the nearest Suspense fallback for deferred props; the other adapters render their loading state. As data becomes available, the server sends it in inline scripts on the same response.
A route with ssr = false still receives those scripts, even though its component mounts in the browser. During client-side navigation, deferred data arrives as newline-delimited JSON on the navigation response.
Deferred Props After Mutations
INFO
When a mutation runs, the loader runs again to provide fresh props, but deferred props cannot stream over a mutation response. The client preserves the last resolved value for each deferred prop, so the UI keeps showing the previous data until the next full page load or SPA navigation.
Grouped Deferred Props
When multiple props depend on the same slow operation, use a named group to resolve them together with a single function 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),
};
});Both metrics and chart resolve from a single invocation of the analytics resolver. The component receives them as separate Deferred<T> props.