Server Loaders

← docs

Server Loaders

Pages often need data. On the server, that data should come from a direct function call. In the browser during navigation, it goes through an RPC call to the server. Writing this branch manually is error-prone — the loader system handles it automatically.

How it works

Define a serverLoader as the default export of a .server.ts file. That single function handles all three load cases:

  • movies.server.ts — the loader; runs directly on the server or via RPC from the browser
  • movies.tsx — imports the loader and passes it to getLoaderData

At runtime:

  1. SSR: serverLoader runs directly during prerender. Its return value is JSON-serialized into a data-loader attribute on the page's wrapper element.
  2. Hydration (first load): The client reads that attribute — no fetch is fired.
  3. Client-side navigation: The Vite plugin replaces the serverLoader import with an RPC stub that POSTs { module, location } to POST /__loaders. The server runs the real serverLoader and returns JSON.

Example: listing page

src/pages/movies.server.ts — server-only data fetching:

import { getMovies } from '@/server/movies.js';
import type { Loader } from '@hono-preact/iso';

const serverLoader: Loader<{ movies: MovieList }> = async () => {
  const movies = await getMovies(); // direct call — never runs in the browser
  return { movies };
};

export default serverLoader;

src/pages/movies.tsx — the page component:

import { getLoaderData, type LoaderData } from '@hono-preact/iso';
import serverLoader from './movies.server.js';
import { createCache } from '@hono-preact/iso';

const cache = createCache<{ movies: MovieList }>();

const Movies: FunctionComponent<LoaderData<{ movies: MovieList }>> = ({ loaderData }) => {
  return (
    <ul>
      {loaderData?.movies.results.map(m => <li key={m.id}>{m.title}</li>)}
    </ul>
  );
};

export default getLoaderData(Movies, { serverLoader, cache });

Example: detail page (using route params)

Loader<T> receives { location } which carries location.pathParams. Use this to load a record by ID:

// src/pages/movie.server.ts
import { getMovie } from '@/server/movies.js';
import type { Loader } from '@hono-preact/iso';

const serverLoader: Loader<{ movie: Movie }> = async ({ location }) => {
  const movie = await getMovie(location.pathParams.id);
  return { movie };
};

export default serverLoader;

Registering the loader endpoint

Register loadersHandler on your Hono app before the catch-all route. It accepts the same import.meta.glob pattern as actionsHandler:

import { loadersHandler, actionsHandler, renderPage, location } from '@hono-preact/server';

app
  .post('/__loaders', loadersHandler(import.meta.glob('./pages/*.server.ts')))
  .post('/__actions', actionsHandler(import.meta.glob('./pages/*.server.ts')))
  .use(location)
  .get('*', (c) => renderPage(c, <Layout context={c} />, { defaultTitle: 'my-app' }));

loadersHandler derives the module name from the filename by stripping the path prefix and .server.* extension — the same convention as actionsHandler.

Caching navigation results

Pass a cache to getLoaderData so repeated navigations to the same page don't re-fetch:

import { createCache } from '@hono-preact/iso';

const cache = createCache<{ movies: MovieList }>();

export default getLoaderData(Movies, { serverLoader, cache });

On a cache hit, the RPC call is skipped entirely. After a successful fetch the result is stored in the cache for the session.

Named caches

Pass a string to createCache to register the cache with the global cacheRegistry. Actions on other pages can then invalidate it by name using invalidate: ['cache-name']:

// movies.tsx
const cache = createCache<{ movies: MovieList }>('movies');
// reviews.tsx — a different page whose action should also refresh the movie list
const { mutate } = useAction(serverActions.addReview, {
  invalidate: ['movies'], // clears the 'movies' cache on success
});

Unnamed caches (createCache<T>()) work exactly as before and cannot be targeted by other pages.

The server/client boundary

Two Vite plugins enforce that .server.* code never reaches the browser. serverOnlyPlugin rewrites *.server.* imports in the client bundle: the default export becomes an RPC stub, and any imported serverGuards, serverActions, or actionGuards become empty arrays or Proxies. serverLoaderValidationPlugin fails the build if a .server.* file has any named export other than those three. See Overview — The server/client boundary for the full explanation.