Skip to content
Built with

Loaders and props ​

Build & Deploy

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.

ts
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.

tsx
// 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>
  );
}
vue
<!-- 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>
svelte
<!-- 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>
tsx
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.

ts
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.

tsx
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:

ts
type Deferred<T> =
  | { loading: true; value: null; error: null } // pending
  | { loading: false; value: T; error: null } // resolved
  | { loading: false; value: null; error: Error }; // rejected

This step checks loading first, then error. TypeScript narrows value to T in the resolved branch.

vue
<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>
svelte
<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>
tsx
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.

ts
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.

Next steps ​

implemented.stream AI