RouterEdit this page.md

Resources

@lazarv/react-server includes a typed resource layer for schema-validated, route-aware data fetching. Resources build on the same principles as the typed router — descriptors define shape, implementations bind behavior, and everything is type-safe end-to-end.

Resources use "use cache" as the entire caching runtime. There is no custom cache layer, no SWR logic, no TTL configuration. If you want caching, add "use cache" to your loader function. On the client, "use cache" supports browser storage providers — sessionStorage, localStorage, IndexedDB, in-memory — via the Unstorage engine, or any custom provider.

Use createResource to define resource descriptors. Descriptors carry the key schema (the shape of the lookup key) but no implementation. They are client-safe — import them from both server and client components.

resources.ts
import { createResource } from "@lazarv/react-server/resources"; import { z } from "zod"; // Keyed resource — validated with Zod export const userById = createResource({ key: z.object({ id: z.coerce.number().int().positive() }), }); // Singleton resource — no key export const currentUser = createResource(); // Lightweight parse — no schema library needed export const postBySlug = createResource({ key: { slug: String }, }); // Complex key export const posts = createResource({ key: z.object({ page: z.coerce.number().default(1), tag: z.string().optional(), }), });

The key schema supports the same validation strategies as route params:

StrategyExampleLibrary
Schema validationz.object({ id: z.coerce.number() })Zod, ArkType, Valibot
Lightweight parse{ id: Number }None (built-in constructors)
No key (singleton)createResource()None

Bind a loader function to a descriptor using .bind(loaderFn). The loader is just an async function — add "use cache" to its body for caching. .bind() mutates the descriptor and returns it with the TData type narrowed to the loader's return type — capture the return value for typed .use() calls. Loaders can live on the server or the client.

Server-side loaders

For simple resources, define the descriptor and bind the loader in a single file:

resources/user.ts
import { createResource } from "@lazarv/react-server/resources"; import { z } from "zod"; export const userById = createResource({ key: z.object({ id: z.coerce.number().int().positive() }), }).bind(async ({ id }) => { "use cache"; return db.users.findById(id); }); export const currentUser = createResource().bind(async () => { return (await getSession()).user; });
resources/post.ts
import { createResource } from "@lazarv/react-server/resources"; export const postBySlug = createResource({ key: { slug: String }, }).bind(async ({ slug }) => { "use cache"; return db.posts.findBySlug(slug); });

Client-side loaders

Loaders can also live on the client — "use cache" caches the result in the browser using any supported storage provider (sessionStorage, localStorage, IndexedDB, in-memory, or a custom Unstorage driver). No server round-trip required.

The loader implementation is abstract — it can be any async function: a fetch() call, a WebSocket message, reading from an IndexedDB store, or computing derived data. Mark the module with "use client" so server code never reaches the client bundle.

resources/search.ts
"use client"; import { createResource } from "@lazarv/react-server/resources"; export const searchResults = createResource({ key: { q: String }, }).bind(async ({ q }) => { "use cache"; const res = await fetch(`/api/search?q=${q}`); return res.json(); });

For routes that load data entirely in the browser, use a dual-loader pattern with a directory structure: a shared descriptor, a server loader, and a client loader.

The shared descriptor holds the resource identity. Both the server and client loaders bind to it, ensuring SSR hydration works across module boundaries. The client module is marked with "use client" so server code never reaches the client bundle.

resources/todos/resource.ts
// Shared descriptor — both server and client loaders bind to this import { createResource } from "@lazarv/react-server/resources"; export const todos = createResource({ key: { filter: String }, });
resources/todos/server.ts
import { todos as resource } from "./resource"; export const todos = resource.bind(async ({ filter }) => { "use cache: request"; return db.todos.list({ filter }); });
resources/todos/client.ts
"use client"; import { todos as resource } from "./resource"; export const todos = resource.bind(async ({ filter }) => { "use cache"; const res = await fetch(`/api/todos?filter=${filter}`); return res.json(); }); // Export route-resource binding as a client reference. // Since this is a "use client" module, the export passes through RSC // serialization and resolves on the client for client-only navigation. export const todosClientMapping = todos.from(({ search }) => ({ filter: search.filter ?? "all", }));

The component calls .use() directly — it suspends (via React.use()) until the data is available. Use the route's loading prop to show a fallback during suspension. When the resource is invalidated, components that called .use() automatically re-render and re-fetch fresh data.

TodosPage.tsx
"use client"; import { todos } from "./resources/todos/client"; export default function TodosPage() { const data = todos.use({ filter: "active" }); return <ul>{data.items.map(t => <li key={t.id}>{t.title}</li>)}</ul>; }

In the router, place server and client bindings side by side in the resources array. Route.jsx automatically partitions them — server bindings are loaded on the server, client references pass through RSC for client-only navigation:

resources/mappings.ts
import { todos } from "./todos/server"; export const todosServerMapping = todos.from(({ search }) => ({ filter: search.filter ?? "all", }));
router.tsx
import { todosServerMapping } from "./resources/mappings"; import { todosClientMapping } from "./resources/todos/client"; const router = createRouter({ todos: createRoute(routes.todos, <TodosPage />, { loading: TodosLoading, resources: [todosServerMapping, todosClientMapping], }), });

The resources prop accepts both server-side binding arrays (loaded on the server) and client references from "use client" modules. Client references are opaque on the server — they pass through RSC serialization and resolve on the client, where the Route component automatically registers them for client-only navigation pre-loading.

.use(key) — suspense-integrated hook

The primary way to consume resources in components. Calls the loader, validates the key, and suspends (via React.use()) until the data is available.

When a resource is invalidated, all components that called .use() on that resource automatically re-render and re-fetch. The loading fallback (from the route's loading prop or a <Suspense> boundary) is shown while the fresh data loads.

UserPage.tsx
import { user as userRoute } from "./routes"; import { userById, currentUser } from "./resources"; export default function UserPage() { const { id } = userRoute.useParams(); const userData = userById.use({ id }); const me = currentUser.use(); return ( <div> <h1>{userData.name}</h1> <p>{userData.email}</p> {me.id === userData.id && <EditButton userId={id} />} </div> ); }

Resources are not coupled to routes — they work anywhere:

function UserTooltip({ userId }) { const user = userById.use({ id: userId }); return <span>{user.name}</span>; }

.query(key) — imperative, returns Promise

Use outside React's render cycle — event handlers, server actions, scripts.

const user = await userById.query({ id: 42 });

.prefetch(key) — warm cache, no suspend

Fire-and-forget. Triggers the loader to populate the cache without waiting for the result.

userById.prefetch({ id: 42 });

Error handling

Invalidation clears the cached data and triggers a re-render of all components currently using the resource. Components re-suspend while fresh data loads — the route's loading fallback (or the nearest <Suspense> boundary) is shown automatically.

Under the hood, invalidation delegates to the existing invalidate() function from @lazarv/react-server/cache. The resource layer adds no custom cache logic — it clears the internal thenable cache (used for React.use() referential stability) and notifies subscribed components via useSyncExternalStore.

Invalidation works on both the server and the client. On the client, it clears entries from whichever browser storage provider "use cache" is using (sessionStorage, localStorage, IndexedDB, in-memory, or a custom Unstorage driver).

// Invalidate all cached entries for a resource userById.invalidate(); // Invalidate a specific entry userById.invalidate({ id: 42 });

Usage from server actions

actions.ts
"use server"; import { userById, posts } from "./resources"; export async function updateUser(id, data) { await db.users.update(id, data); userById.invalidate({ id }); } export async function deleteUser(id) { await db.users.delete(id); userById.invalidate({ id }); posts.invalidate(); // also invalidate posts }

Client-side invalidation

On the client, invalidation clears the browser cache AND triggers a re-render. Components that called .use() re-suspend, the loading fallback is shown, and fresh data is fetched.

TodosPage.tsx
"use client"; import { todos } from "./resources/todos/client"; function RefreshButton({ filter }) { return ( <button onClick={() => todos.invalidate({ filter })}> Refresh </button> ); }

Group resources into a typed registry with createResources. Provides invalidateAll() to bust cached entries across all resources at once.

resources/index.ts
import { createResources } from "@lazarv/react-server/resources"; import { userById } from "./user"; import { currentUser } from "./current-user"; import { posts } from "./posts"; export const resources = createResources({ userById, currentUser, posts }); // Invalidate everything resources.invalidateAll();

Routes declare resource dependencies via the resources option on createRoute. When a route matches, all bound resources are loaded in parallel and the route waits for the data before rendering the component tree. This eliminates sequential waterfalls — instead of components calling .use() one by one, all loaders run concurrently.

Resources also work without route bindings. Any component can call .use() directly — it suspends until the data is available. Route-resource binding is an optimization that moves loading to the route level.

router.tsx
import { createRoute, createRouter } from "@lazarv/react-server/router"; import * as routes from "./routes"; import { userById } from "./resources/user"; import { currentUser } from "./resources/current-user"; import { posts } from "./resources/posts"; const router = createRouter({ user: createRoute(routes.user, <UserPage />, { resources: [ // .from() maps { params, search } → resource key userById.from(({ params }) => ({ id: params.id })), // singleton resources don't need .from() currentUser, ], }), posts: createRoute(routes.posts, <PostListPage />, { resources: [ // Load current + next page in parallel posts.from(({ search }) => ({ page: search.page ?? 1, tag: search.tag, })), posts.from(({ search }) => ({ page: (search.page ?? 1) + 1, })), ], }), }); export default router;

.from(mapFn)

Returns a { resource, mapFn } binding tuple. Each .from() is a separate binding — you can bind the same resource multiple times with different key mappings (e.g., load current + next page in parallel).

For dual-loader resources, place client bindings (from "use client" modules) alongside server bindings in the resources array. Route.jsx partitions them automatically — no special wiring needed.

Navigation flow

Server routes:

  1. Navigation starts (Link click, useNavigate(), back/forward)
  2. Server renders the route, resources: [...] bindings are found
  3. All resource loaders run in parallel — the route waits for all of them
  4. Component renders, calls .use() — data already loaded, no suspension

Client-only routes (with client resource bindings):

  1. Navigation starts
  2. Route-registered loaders fire in parallel (fire-and-forget)
  3. URL updates, component renders, calls .use()
  4. If data is ready — renders immediately. If still loading — suspends with loading fallback

Client-only navigation

Resource bindings contain mapFn functions that can't be serialized directly from server to client. The solution: define the bindings in a "use client" module and export them. Since "use client" exports are client references, they pass through RSC serialization and resolve on the client.

Place server bindings and client references side by side in the resources array. Route.jsx partitions them automatically by $$typeof — server bindings load on the server, client references pass through RSC:

resources/todos/client.ts
"use client"; import { todos as resource } from "./resource"; export const todos = resource.bind(async ({ filter }) => { "use cache"; const res = await fetch(`/api/todos?filter=${filter}`); return res.json(); }); // Exported as a client reference — passes through RSC to the client export const todosClientMapping = todos.from(({ search }) => ({ filter: search.filter ?? "all", }));
router.tsx
import { todosServerMapping } from "./resources/mappings"; import { todosClientMapping } from "./resources/todos/client"; createRoute(routes.todos, <TodosPage />, { // Server binding loads on initial request; client reference passes // through RSC for client-only navigation pre-loading. resources: [todosServerMapping, todosClientMapping], });

The Route component detects client references and passes them through to the client, where they are automatically registered for pre-loading during client-only navigation.

For advanced cases (dynamic registration, resources not tied to a Route), registerRouteResources from @lazarv/react-server/navigation is also available as a programmatic API.

Prefetching

Prefetching is separate from route loading. Use the .prefetch() method on a resource descriptor to warm the cache ahead of time — for example on Link hover via the built-in prefetch prop, or programmatically before a navigation you anticipate.

// Programmatic prefetch — fire-and-forget userById.prefetch({ id: 42 });

The route's loading prop provides a loading fallback for both server and client component routes. When a component calls .use() and the data isn't ready yet, the loading component is shown automatically via <Suspense>.

router.tsx
const router = createRouter({ todos: createRoute(routes.todos, <TodosPage />, { loading: TodosLoading, resources: [...], }), });
TodosLoading.tsx
"use client"; export default function TodosLoading() { return <div>Loading todos...</div>; }

The loading fallback is shown:

Resources are identified by object reference, not string names. There is no name registry, no string key collisions. This is what makes dual-loader resources work: both the server and client loaders call .bind() on the same descriptor object, so .use() in any component finds the correct loader.

resources/todos/resource.ts
// Shared descriptor — the single source of identity export const todos = createResource({ key: { filter: String } });
resources/todos/server.ts
import { todos as resource } from "./resource"; // .bind() attaches the server loader to the shared descriptor export const todos = resource.bind(async ({ filter }) => { /* ... */ });
resources/todos/client.ts
"use client"; import { todos as resource } from "./resource"; // Same descriptor reference — .bind() attaches the client loader export const todos = resource.bind(async ({ filter }) => { /* ... */ });

For simple (non-dual-loader) resources, identity is implicit — the descriptor and loader are defined in the same file via createResource({ ... }).bind(loaderFn).

The file-system based router supports declarative, route-bound data fetching. Instead of manually wiring createResource, .bind(), and .from(), you export a few named values and the router generates all the boilerplate automatically. To create a resource file, add a .resource segment to the filename — for example, todos.resource.ts.

Resource descriptors created from resource files are exposed via the @lazarv/react-server/resources virtual module. The resources object is a typed collection of named descriptors — the name is derived from the filename (e.g. todos.resource.tsresources.todos).

Convention

A resource file exports the following named exports:

ExportRequiredDescription
keynoResource key schema (e.g. { filter: String }, a Zod schema, etc.). Omit for singleton resources.
loaderyesAsync function that fetches data given a validated key.
mappingyesFunction ({ params, search }) => key that maps route parameters and search parameters to the resource key. Use route.createResourceMapping(fn) for type-safe params and search.
namenoOverride the auto-derived resource name.

The filename determines the route path, just like with other file-system based router files. For example, todos.resource.ts binds a resource to the /todos route.

pages/todos.resource.ts
import { todos } from "@lazarv/react-server/routes"; export const key = { filter: String }; export const loader = async ({ filter }) => { "use cache"; return db.todos.list({ filter }); }; export const mapping = todos.createResourceMapping(({ search }) => ({ filter: search.filter ?? "all", }));

The page component accesses the resource via the resources collection:

pages/todos.page.tsx
"use client"; import { resources } from "@lazarv/react-server/resources"; export default function TodosPage() { const data = resources.todos.use({ filter: "all" }); return <ul>{data.items.map(t => <li key={t.id}>{t.title}</li>)}</ul>; }

How it works

The router does all the wiring for you. When it finds a resource file, it:

  1. Creates a descriptorcreateResource({ key }) using your exported key
  2. Binds the loader.bind(loader) attaches your exported loader function
  3. Creates the route binding.from(mapping) maps route params/search to the resource key
  4. Registers the binding — adds the resource to the route's resources array so it loads in parallel on navigation
  5. Adds to the collection — exposes the descriptor as resources.<name> in the @lazarv/react-server/resources virtual module

This means you never need to call createResource, .bind(), or .from() yourself in resource files — just export the pieces.

Under the hood, the router generates exactly what you would write by hand:

Manual (typed router)Resource file equivalent
createResource({ key })export const key = ...
.bind(loaderFn)export const loader = ...
.from(mapFn) in route configexport const mapping = ...
createResources({ ... })Auto-generated resources collection

Resource naming

The resource name — used as the key in the resources collection — is derived from the filename:

FilenameDerived name
todos.resource.tstodos
user-profile.resource.tsuserProfile
(server).todos.resource.tstodos
(client).todos.resource.tstodos
posts/index.resource.tsposts

Parenthesized prefixes like (server) and (client) are stripped. Hyphens are converted to camelCase.

Override the derived name by exporting name:

pages/todos.resource.ts
export const name = "todoList"; // → resources.todoList

Client resource files

Add "use client" at the top of a resource file to make it a client-side loader. Client resources run entirely in the browser — no server round-trip. The data is cached client-side via "use cache" using any supported storage provider (sessionStorage, localStorage, IndexedDB, in-memory, or a custom Unstorage driver).

pages/search.resource.ts
"use client"; import { search as searchRoute } from "@lazarv/react-server/routes"; export const key = { q: String }; export const loader = async ({ q }) => { "use cache"; const res = await fetch(`/api/search?q=${q}`); return res.json(); }; export const mapping = searchRoute.createResourceMapping(({ search }) => ({ q: search.q ?? "", }));

Dual-loader resource files

Use a parenthesized prefix to bind multiple resources to the same route path. The prefix is stripped from the path — it serves only as a label to distinguish files.

pages/ todos.page.tsx todos.loading.tsx (server).todos.resource.ts # Server loader — loads on initial request (client).todos.resource.ts # Client loader — takes over on navigation

Both files bind to /todos and share the same resource name (todos). On the initial server render, the server resource loads data and hydrates it to the client. On subsequent client-side navigations, the client resource runs its loader directly in the browser. Both resource files should use the same key schema so hydration injection works correctly.

pages/(server).todos.resource.ts
import { todos } from "@lazarv/react-server/routes"; import { loadTodos } from "../src/todos-loader"; export const key = { filter: String }; export const loader = async ({ filter }) => { "use cache: request"; return loadTodos({ filter }); }; export const mapping = todos.createResourceMapping(({ search }) => ({ filter: search.filter ?? "all", }));
pages/(client).todos.resource.ts
"use client"; import { todos } from "@lazarv/react-server/routes"; import { loadTodos } from "../src/todos-loader"; export const key = { filter: String }; export const loader = async ({ filter }) => { "use cache"; return loadTodos({ filter }); }; export const mapping = todos.createResourceMapping(({ search }) => ({ filter: search.filter ?? "all", }));

The page component uses resources.todos from the collection — no need to import from individual resource files:

pages/todos.page.tsx
"use client"; import { resources } from "@lazarv/react-server/resources"; export default function TodosPage() { const data = resources.todos.use({ filter: "all" }); return ( <div> <ul>{data.items.map(t => <li key={t.id}>{t.title}</li>)}</ul> <button onClick={() => resources.todos.invalidate({ filter: "all" })}> Refresh </button> </div> ); }

When dual resources share the same name, the router prefers the client descriptor for the resources collection. This ensures that resources.todos.use() in a client component resolves to the client loader on navigation, while the server loader is used for the initial server render via the route binding.

createResource(options?)

Create a resource descriptor (no loader).

OptionTypeDescription
keyValidateSchema | Record<string, Function>Key schema or parse map. Omit for singletons.

Returns a ResourceDescriptor with .use(), .query(), .prefetch(), .invalidate(), .from().

.bind(loaderFn)

Bind a loader to a descriptor. Mutates the descriptor and returns it with the TData type narrowed to the loader's return type.

const userById = createResource({ key: z.object({ id: z.coerce.number() }), }).bind(async ({ id }) => { "use cache"; return db.users.findById(id); });
ParamTypeDescription
loaderFn(key) => T | Promise<T>Data fetching function. Add "use cache" for caching.

Returns the same ResourceDescriptor with TData narrowed to the loader's return type.

createResources(resources)

Collect resources into a registry.

ParamTypeDescription
resourcesRecord<string, ResourceDescriptor>Named resources

Returns the resources plus invalidateAll().

ResourceDescriptor methods

MethodDescription
.use(key?)React hook — suspends until data available. Re-renders on invalidation.
.query(key?)Imperative — returns Promise
.prefetch(key?)Fire-and-forget cache warming
.invalidate(key?)Clear cache and trigger re-render of all .use() consumers
.from(mapFn)Create route binding. Place alongside client bindings in the resources array for dual-loader routes.
.bind(loaderFn)Attach a loader function. Returns the descriptor with narrowed TData type.

registerRouteResources(path, resources)

Programmatic API for registering resource bindings for client-only navigation. In most cases, prefer passing client resource bindings via the resources prop on createRoute — the Route component handles registration automatically.

Use this for advanced cases: dynamic registration, resources not tied to a Route component, or imperative setup.

ParamTypeDescription
pathstringRoute path pattern (e.g. "/todos")
resources(RouteResourceBinding | RouteResource)[]From .from() or bare descriptors

Returns a cleanup function.

import { registerRouteResources } from "@lazarv/react-server/navigation"; registerRouteResources("/todos", [ todos.from(({ search }) => ({ filter: search.filter ?? "all" })), ]);