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 browsermovies.tsx— imports the loader and passes it togetLoaderData
At runtime:
- SSR:
serverLoaderruns directly duringprerender. Its return value is JSON-serialized into adata-loaderattribute on the page's wrapper element. - Hydration (first load): The client reads that attribute — no fetch is fired.
- Client-side navigation: The Vite plugin replaces the
serverLoaderimport with an RPC stub that POSTs{ module, location }toPOST /__loaders. The server runs the realserverLoaderand 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.