API Client
Call app API routes with apiClient.hello.get style inference, cache policies, invalidation, retries, callbacks, and optimistic updates.
Create both callers once
import { createApiClients } from "@farm.js/core/client";
import { apiRoutes, type APIRouter } from "./api.generated";
export const { api, apiClient } = createApiClients<APIRouter>({ routes: apiRoutes });Define each endpoint once in a file route or plugin. This shared module imports only generated
paths/methods and types, never server handlers or credentials. Import api in server code and
apiClient in browser code; do not create another caller in either component.
Both app-route callers return { data, error, key } and preserve the same input, output, method,
and dynamic-parameter inference. Keep secrets out of this shared module, including its options.
If the app also has integrations, add AppIntegrations as the second type argument to this same
factory. You do not need a second caller setup. See Integration callers.
The HTTP client uses the current origin and /api by default. To point every default client at another
API, configure it once in farm.config.ts:
import { defineConfig } from "@farm.js/core";
export default defineConfig({
api: {
baseURL: () => process.env.API_ORIGIN,
basePath: "/api",
},
});https://api.example.com becomes https://api.example.com/api. A URL that already has a path,
such as https://api.example.com/v1, uses that path directly. baseURL and basePath may be sync
or async resolver functions; Farm evaluates them during config resolution and embeds only the
resulting public URL.
For a cross-origin API that uses cookies or HTTP authentication, pass the browser fetch credential mode when creating the client:
export const { api, apiClient } = createApiClients<APIRouter>({
baseURL: "https://api.example.com/v1",
credentials: "include",
});Farm forwards credentials to every route request from that client. The API must also allow the
calling origin and credentialed requests through its CORS policy.
Header defaults
Instance headers accepts a string-valued object or a function returning one. The function can
be synchronous or asynchronous; Farm calls it when an operation is called, not when the client
is created.
import { createApiClients } from "@farm.js/core/client";
import { apiRoutes, type APIRouter } from "./api.generated";
export const { api, apiClient } = createApiClients<APIRouter>({
routes: apiRoutes,
headers: () => ({
"Accept-Language":
typeof document === "undefined" ? "en" : document.documentElement.lang || "en",
}),
});
// This call overrides the instance language without changing later calls.
await apiClient.hello.get({ headers: { "accept-language": "fr" } });Use headers: async () => ({ ... }) when obtaining defaults requires asynchronous work.
The exported ClientHeaders type describes all three forms. Static objects remain supported;
per-call headers remain objects, not resolver functions.
Header names are case-insensitive. App routes merge forwarded server request headers first,
then instance defaults, then per-call headers. Thus the example explicitly chooses English on
the server; omit its Accept-Language default when you want api to preserve the incoming
request's language. The existing server forwarding allowlist is unchanged.
Farm snapshots the resolved defaults once per operation call, before cache lookup or dispatch. Cache hits still resolve headers; changed effective headers isolate the route client's cache and in-flight deduplication. Retries reuse the same snapshot. A thrown error, rejected promise, or invalid header produces the normal error result without fetching or returning cached data.
These defaults also apply to api.integrations and apiClient.integrations when enabled.
Providing integrations.headers replaces the shared header defaults for integration calls only;
it does not merge the two resolvers. See integration header defaults
for integration-specific precedence and separate server defaults.
A resolver is not a server-only boundary. Anything imported into this shared module may reach the browser, and the resolver runs wherever its caller runs. Do not put provider credentials or private environment values here. Read request-specific values inside the resolver or a request-scoped server module, never by mutating a shared singleton with one user's credentials.
Cancellation and deadlines
Set timeoutMs on createApiClients for a default deadline, or override it per call:
export const { api, apiClient } = createApiClients<APIRouter>({
routes: apiRoutes,
timeoutMs: 10_000,
});
const controller = new AbortController();
const pending = apiClient.hello.get({}, { signal: controller.signal, timeoutMs: 2_000 });
controller.abort();
const result = await pending;
// result.error.code is "aborted" (or "timeout" when Farm's deadline expires).The budget starts at call time and includes asynchronous header resolution, transport,
response decoding, and retry waits. 0 disables the deadline (also the default); accepted
values are integers from 0 to 2147483647. A per-call 0 disables an instance deadline.
Keep signal per call, not on a shared long-lived instance.
Cancellation returns { data: undefined, error, key } with status 0, stops retries, skips
cached results when already aborted, and rolls back pending optimistic updates even when
rollbackOnError is false. Calls with a signal or deadline do not participate in in-flight
deduplication, so one caller cannot cancel another. Completed results can still use the cache.
Background revalidation retains its call budget until it finishes. A returned stream is handed
to the caller: the deadline ends at that handoff, not at the end of stream consumption; the
caller's signal still reaches the underlying HTTP request.
Local api dispatch combines the call signal with the incoming request signal. Handlers can
pass request.signal to cancellable work. Farm stops waiting when cancelled, but a handler or
custom transport that ignores the signal may continue running. Cancellation cannot undo
completed writes or external side effects.
These defaults also reach integration callers; integrations.timeoutMs overrides the shared
deadline. See integration cancellation.
Custom HTTP transport
Pass a fetch-compatible function when a caller needs an HTTP wrapper for testing or tracing:
export const { api, apiClient } = createApiClients<APIRouter>({
routes: apiRoutes,
fetch: async (url, init) => {
const response = await globalThis.fetch(url, init);
return response;
},
});The function has the same type as globalThis.fetch and receives Farm's resolved URL and
RequestInit, including headers, credentials, body, and cancellation signal. Return a normal
Response and forward the signal in wrappers. Farm still handles decoding, errors, retries,
and deadlines. Without this option, Farm uses global fetch as before. Supply a bound function
if your implementation requires a particular this value.
This replaces HTTP only: apiClient uses it, but server api still dispatches app routes
locally. Integration HTTP calls (including server HTTP fallback) inherit it;
integrations.fetch overrides it for integrations. Registered local integration handlers
do not use HTTP and are unchanged.
App-route caches stay private to instances with a custom transport, even with
cache.scope: "shared" or credentials: "omit": wrappers can add identity that Farm cannot
see. If a wrapper changes users internally, also reflect that identity in the caller's
header resolver or create a new instance; Farm cannot detect hidden identity changes.
Shared lifecycle hooks
Set onRequest, onResponse, or onError on the caller instance for shared logging or error
reporting. These are ordinary callbacks, not React hooks:
export const { api, apiClient } = createApiClients<APIRouter>({
routes: apiRoutes,
onRequest(event) {
console.debug(event.method, event.path);
},
onResponse(_data, _error, event) {
console.debug("Status:", event.status);
},
onError(error) {
console.error(error.message);
},
});
await apiClient.hello.get(
{},
{
onError(error) {
// Handle this call's error in the UI as well as shared reporting.
console.debug("Could not load greeting:", error.message);
},
},
);Shared hooks run first, then the corresponding per-call hook. Per-call route response types
remain inferred. Shared hooks use unknown data because one instance covers many routes;
ClientLifecycleHooks, ClientRequestEvent, and ClientResponseEvent are exported for reusable
observers. Return values are ignored. Promises are not awaited, and thrown/rejected shared
hooks are reported through globalThis.reportError (or console) without failing the call.
Asynchronous work may finish out of order even though callbacks are invoked shared-first.
onRequest/onResponse observe execution attempts, including retries and background refreshes,
not cache hits. Deduplicated requests share attempt events. onError runs once on final failure
for each logical call, including background failures; it does not run for each failed retry.
An attempt can fail before HTTP dispatch, such as header resolution or cancellation. A response
event observes a completed attempt; aborting inside it cannot retroactively cancel that result.
These hooks do not add retries or replace existing per-call success, settlement, or status hooks.
Shared defaults apply to integration callers too. integrations.onRequest, onResponse, and
onError replace the corresponding shared default for integrations; per-call hooks still compose.
Local server calls run their hooks on the server; HTTP callers run them where invoked. Keep
shared observers browser-safe and avoid logging credentials, request bodies, or personal data.
Call a route
const result = await apiClient.hello.post({
body: { name: "Ada" },
});
if (result.error) {
console.error(result.error);
} else {
console.log(result.data.message);
}If a route path contains a lowercase HTTP method segment such as get, post, or delete that
collides with a method on its parent route, the generated client exposes a leading-slash literal
alias so the two cannot be confused:
// Both src/app/api/users/route.ts and src/app/api/users/get/route.ts export GET.
const result = await apiClient["/users/get"].get();The leading slash marks the whole key as a literal API path. This also works when the method-named
segment is in the middle of a colliding route, for example
apiClient["/users/get/profile"].post(...). Non-conflicting paths keep their ordinary nested form.
A typed HEAD route is called with .head(). Its result keeps the same { data, error, key }
shape, with data set to undefined because HTTP HEAD responses do not have a body.
Array-valued query inputs use repeated URL parameters. For example,
await apiClient.posts.get({ query: { tag: ["react", "vite"] } });
// GET /api/posts?tag=react&tag=viteThis is the same array representation that API route query schemas receive.
Scoped dynamic routes
farm generate, development startup, and production builds emit apiRoutes alongside
APIRouter in src/lib/api.generated.ts. Pass that manifest to enable parameter resolution:
import { createApiClients } from "@farm.js/core/client";
import { apiRoutes, type APIRouter } from "./api.generated";
export const { api, apiClient } = createApiClients<APIRouter>({ routes: apiRoutes });For POST /api/projects/[projectId]/uploads/[uploadId]:
const project = apiClient.projects.$params({ projectId: "project-123" });
const result = await project.uploads.post({
params: { uploadId: "upload-456" },
body: { title: "Design draft" },
});
if (result.error) throw result.error;
console.log(result.data?.title);$params() returns a reusable, immutable caller and sends no request. Only the explicit HTTP
method executes the call. Bound parameters do not have to be repeated and cannot be overwritten
by a descendant call. You can bind the final parameter as well:
const upload = project.uploads.$params({ uploadId: "upload-456" });
await upload.post({ body: { title: "Updated draft" } });The generated types describe registered methods, required parameters, validated input, and JSON response data. The runtime manifest contains only paths and methods; importing it never imports the config, server handlers, validators, or credentials into the browser. Existing static clients without a manifest remain supported.
Binding preserves position: /files/[id]/versions uses
apiClient.files.$params({ id }).versions.get(), while /files/versions/[id] uses
apiClient.files.versions.get({ params: { id } }). Catch-all parameters use arrays:
apiClient.docs.$params({ parts: ["guides", "start"] }).get() for /api/docs/[...parts].
Optional catch-alls can bind an empty object. Intermediate parameters must be bound before
accessing their children through the shorthand; existing bracket-pattern access remains available.
The client encodes parameter values, respects the configured API base URL/path, and rejects
missing/unknown parameters, unsafe values, unregistered methods, and ambiguous calls before
fetching. A supplied undefined ID never falls back to a collection request. Static server
routes still win: if /api/uploads/stats exists, calling /api/uploads/[id] with id: "stats"
throws a shadowing error rather than calling the wrong endpoint. Choose non-conflicting IDs or paths.
Resolved URLs participate in caching and invalidation, so different bound IDs remain separate.
Bind the final parameter before passing a route reference to a hook or invalidation helper when
you need one concrete resource; collection/detail overloads otherwise describe multiple inputs.
Plugin paths containing HTTP method names use a literal alias such as
apiClient["/projects/get"].get(). $params is reserved for the scope helper.
Both callers returned by createApiClients() support these scopes. Use api for local calls
during a Farm server request and apiClient for HTTP calls. For standalone HTTP calls from server
code, give apiClient a trusted absolute baseURL and explicitly forward only the credentials
the target needs. See Server callers for local-dispatch boundaries.
Type-safe QUERY requests
A route that exports QUERY becomes a .query() caller. Its body and response are inferred from
the endpoint, just like the existing .get() and .post() callers:
const result = await apiClient.products.search.query(
{
body: {
filters: [{ field: "category", value: "tools" }],
limit: 20,
},
},
{
cache: {
policy: "stale-while-revalidate",
staleTime: 30_000,
},
},
);
if (!result.error) {
// Inferred from the QUERY handler response.
console.log(result.data.products, result.data.total);
}TypeScript reports an error if body is missing or a filter has the wrong shape. Farm sends the
body as JSON and uses the QUERY method on the wire. Opt-in cache keys include the API origin,
path, URL query parameters, request body, Content-Type, and Content-Encoding. Multipart QUERY
requests need an explicit cache key because a generated multipart boundary cannot be represented
reliably before fetch sends the request.
Farm adds Content-Type: application/json when it serializes a JSON request body. Bodyless
requests do not receive that header, and an explicitly configured content type takes precedence
regardless of header casing.
Upload files and consume progress streams
toFormData() retains the endpoint's body shape while sending files as real multipart fields. When
an endpoint returns jsonStream(), the generated client exposes a typed, single-consumer async
iterable. Concurrent next() calls on the same iterator are served in order, including items
already buffered in a transport chunk. Cancellation interrupts outstanding reads without waiting
for another chunk; cancel() itself still waits for producer cleanup:
import { toFormData } from "@farm.js/core/api";
const result = await apiClient.imports.post({
body: toFormData({
title: "Quarterly report",
file,
}),
});
if (result.error) {
throw result.error;
}
for await (const event of result.data) {
if (event.phase === "accepted") {
console.log(`Uploading ${event.bytes} bytes`);
} else {
console.log(`Imported ${event.imported} rows`);
}
}Farm passes the FormData object directly to fetch, allowing the runtime to generate the required
multipart boundary. Do not set Content-Type manually. Stream items are decoded only as the
consumer advances the iterator, and result.data.cancel() aborts the response reader when the UI
no longer needs progress.
jsonStream() closes its source iterator at most once when cancelled or when a source/serialization
error occurs. A pending source read that finishes after cancellation is discarded. Explicit
cancellation waits for that cleanup and exposes cleanup failures; a source/serialization failure
keeps its original error even if cleanup also fails.
Track mutations in React
useMutation gives generated API methods and Farm server functions the same pending, result, and
error lifecycle. API methods keep using the typed HTTP client underneath; they are not converted
into React Server Actions.
"use client";
import { useMutation } from "@farm.js/core/client";
import { apiClient } from "@/lib/api";
export function CreateProductButton() {
const createProduct = useMutation(apiClient.products.post, {
request: {
invalidate: [[apiClient.products.get]],
},
});
return (
<button
disabled={createProduct.pending}
onClick={() =>
createProduct.mutate({
body: { name: "Strata", category: "tools" },
})
}
>
{createProduct.pending ? "Creating..." : "Create product"}
</button>
);
}Use mutate for event handlers and mutateAsync when later code needs the resolved value:
const product = await createProduct.mutateAsync({
body: { name, category },
});The return value includes data, error, variables, status, pending, and reset. Pass the
existing API-client cache, retry, invalidation, and optimistic options through request. A
server-function target honors request.retry, request.optimistic, and request.invalidate
with the same shapes; the remaining request options describe API-route transport and continue
to apply to API routes only. Local optimistic state on useMutation is separate from an API
cache update: it controls mutation.data, while request.optimistic updates shared cached
queries.
For a server-function target, optimistic updates and invalidations name explicit structured cache keys, since the shared cache is the surface a server function can reach:
const rename = useMutation(renameProduct, {
request: {
optimistic: {
update: [[["product", id], (current) => ({ ...current, name })]],
rollbackOnError: true,
},
invalidate: [["product", id]],
},
});The updater applies to the shared client cache before the server function runs, so a
useServerQuery watching ["product", id] renders the new name immediately. On success the
update commits and the invalidation travels the shared invalidation bus, exactly like a
server-declared invalidates; on failure rollbackOnError restores the previous entry, and
without it the touched entry is marked stale. Route-reference tuples such as
[apiClient.products.get] need an API caller's route identity and are skipped for
server-function targets; server-side keys the client cannot know about belong in the server
function's own invalidates.
Each local optimistic callback receives the latest scheduled mutation data, even when multiple
submissions occur before React rerenders. With rollbackOnError: true, a failed latest submission
restores the snapshot captured immediately before that submission's optimistic update.
Pause offline submissions
By default a mutation dispatches regardless of connectivity and a submission with no network
fails after its retries. Opt into offline awareness with networkMode: "online":
const createProduct = useMutation(apiClient.products.post, {
networkMode: "online",
});
// createProduct.paused is true while the browser is offline;
// the submission dispatches automatically on the `online` event.While the browser reports offline, a new submission waits in a paused state instead of
dispatching, and a dispatch that failed while offline pauses and rides the next reconnect
instead of surfacing a connectivity error. pending stays true and status stays "pending"
for a paused submission; the new paused flag distinguishes "waiting for connection" from "on
the wire" so the UI can say so. Paused submissions resume in submission order on the online
event. reset() rejects paused submissions immediately rather than leaving them waiting, and a
disowned submission never dispatches after a later reconnect. Optimistic state applied before
the pause stays visible while waiting.
The pause window is in-memory: a reload drops paused submissions, so keep them short-lived and
surface paused to the user. useFetcher accepts the same option and exposes the same flag.
The default networkMode: "always" preserves existing behavior exactly.
For overlapping submissions, status, data, and error describe the latest submission.
Older completions do not replace its result or run completion callbacks. pending separately
tracks whether any submission is still running, so it can remain true after the latest one
succeeds or fails. useFetcher follows the same rules.
Mutation onSuccess, onError, and onSettled callbacks are non-awaited observers. A thrown
error or rejected promise is reported through reportError (or console.error as a fallback),
without changing the target result, rolling back a successful mutation, or decrementing pending
work again. Use the awaited mutateAsync result for required follow-up work. If a callback resets
the hook or submits again, the older submission does not run a stale onSettled callback.
reset() clears the displayed mutation state; it does not cancel the underlying work. Calls
started before reset still settle their own promises, but cannot change the new state or run its
completion callbacks. If you submit again, pending counts only calls started after reset.
useFetcher.reset() follows the same rule.
Submit without navigation
Use useFetcher when a button or form should run an operation without changing the current route.
It accepts generated API methods, Farm server functions, and ordinary async functions:
"use client";
import { useFetcher } from "@farm.js/core/client";
import { apiClient } from "@/lib/api";
export function CreateProductForm() {
const createProduct = useFetcher(apiClient.products.post, {
request: {
invalidate: [[apiClient.products.get]],
},
});
return (
<createProduct.Form>
<input name="name" required />
<input name="category" required />
<button disabled={createProduct.pending}>
{createProduct.pending ? "Creating..." : "Create product"}
</button>
{createProduct.error ? <p role="alert">{createProduct.error.message}</p> : null}
{createProduct.data ? <p>Created {createProduct.data.name}</p> : null}
</createProduct.Form>
);
}The fetcher exposes state (idle or submitting), status, pending, paused, data, error,
variables, the active formData, submit, submitAsync, Form, and reset. It uses the same
optimistic updates, rollback, callbacks, typed errors, and API-client request options as
useMutation.
Generated API forms map fields to { body: ... } by default, or { query: ... } for GET routes.
Use mapFormData when the validated input needs coercion or a different shape:
const quantity = useFetcher(apiClient.cart.post, {
mapFormData(formData) {
return {
body: {
productId: String(formData.get("productId")),
quantity: Number(formData.get("quantity")),
},
};
},
});After hydration, <fetcher.Form> prevents navigation and submits through the typed target. For a
server function, the function itself remains the native form action, preserving React's
progressive-enhancement path before JavaScript loads. Generated GET and POST API forms use the
real endpoint URL as their native fallback; a native fallback navigates to the endpoint response,
while the hydrated fetcher stays on the page.
If mapFormData throws, the target and optimistic callback are not called. The fetcher reports
status: "error", updates error, and runs onError/onSettled with variables: undefined.
This also applies to <fetcher.Form> and fire-and-forget submit; submitAsync rejects with the
same error. formData is cleared when submission finishes. Older work still contributes to
pending, but cannot overwrite this newer error; reset() clears it as usual.
Local mapping failures are wrapped in FetcherInputError from @farm.js/core/client, with
code: "input_error", status: 0 (no HTTP response), and the original thrown value in cause.
Its message preserves an original Error message or thrown string. Fetcher error types and
callbacks include this case alongside the target's existing errors. Narrow with
error instanceof FetcherInputError before handling provider-specific errors. Request-level
callbacks under request still receive only API-client errors, not local mapping failures.
Client options
- cache: choose cache-first, network-only, or stale-while-revalidate.
- retry: retry transient failures with count and delay.
- invalidate: mark typed route keys stale after mutations.
- optimistic: update cached query data before the server response returns.
- onRequest, onResponse, onSuccess, onError, onSettled, and onStatus: observe the full client lifecycle.
onResponse is a transport observer. If it throws or returns a rejected promise, Farm reports that
failure through the platform reportError hook (or the console fallback) without retrying or
changing the completed API result.
Use a structured cache key when an API response intentionally shares data with route data or a createServerQuery:
const { apiClient: publicApi } = createApiClients<APIRouter>({ credentials: "omit" });
const product = await publicApi.products.get(
{ query: { id } },
{
cache: {
key: ["product", id],
scope: "shared",
policy: "stale-while-revalidate",
staleTime: 30_000,
},
},
);Structured keys use Farm's route-data key contract. Default API cache keys include the API origin. Same-origin and otherwise credentialed requests keep their cache private to the created client, and changing explicit headers or credentials clears that private cache. This prevents a new client from reusing a response produced under an earlier cookie identity without placing credential values in a public cache key. Invalidate session-specific reads when the same client logs in or out.
Private caches follow the lifetime of their caller: on runtimes with WeakRef, the shared
invalidation channel does not keep an abandoned cache alive. Cleanup occurs during finalization
or a later invalidation, not on a guaranteed schedule. Keep reusable callers at module scope;
gcTime controls expiration of individual entries, not caller disposal: an expired entry is
evicted on its next read, and a periodic background sweep also removes expired entries that no
consumer is watching, so unread keys do not accumulate in long-lived sessions. Older runtimes
without WeakRef retain the existing strong subscription, so avoid repeatedly creating callers
there. Request-local server caches do not subscribe to the global invalidation channel.
Use scope: "shared" only for public data requested with credentials: "omit" and no custom
headers that intentionally shares a structured key with route data or another API client:
cache: {
key: ["catalog", "featured"],
scope: "shared",
policy: "cache-first",
}Requests with credentials: "omit" and no custom headers may share by default because they carry
no browser identity. scope: "client" can keep those requests private as well. Credentialed or
header-carrying requests remain client-scoped even if scope: "shared" is supplied.
Set cache.dedupeMs to join identical requests started within that window. If an older request is
still running after the window expires, the newer request becomes the cache owner; the older result
still returns to its original caller but cannot replace the newer cached value.
The same ownership rule applies across different caller instances using a shared cache key. The newest network read owns that entry even if it fails or is cancelled; an older pending response cannot restore superseded data. Transport deduplication remains per caller, and private, credentialed, custom-transport, and request-local server caches remain isolated.
Invalidation also protects against reads already in flight: their results still return to their callers, but cannot become fresh cache entries after that key is invalidated. The next read can fetch current data, even when invalidation and request startup happen in the same millisecond. Unrelated cache keys are unaffected.
This includes mutation invalidate targets whose first read has not populated the cache yet.
Explicit keys and typed route references receive the same protection in private, shared, and
request-local server caches. Failed mutations leave those reads unchanged.
Optimistic cache updates
Farm's cache lifecycle is intentionally familiar to React Query and TanStack Query users, but it is implemented by Farm's own typed API client and shared cache. A mutation can update an existing query result immediately, roll it back after an error, and invalidate it after the server responds.
const products = await apiClient.products.get(
{ query: { category } },
{
cache: {
key: ["products", category],
policy: "stale-while-revalidate",
staleTime: 30_000,
},
},
);
const createProduct = apiClient.products.post(
{
body: {
name,
category,
},
},
{
optimistic: {
update: [
[
products.key,
(current) => ({
...current,
products: [{ id: "optimistic", name, category }, ...(current?.products ?? [])],
}),
],
],
rollbackOnError: true,
},
invalidate: [products.key],
},
);
await createProduct;The updater runs synchronously before the POST finishes. products.key preserves the cached
response type, so current is inferred from apiClient.products.get. You can also target a generated
route directly with [apiClient.products.get, { query: { category } }, updater].
With rollbackOnError: true, Farm restores the exact previous cache entry when the mutation fails.
After a successful mutation, invalidation marks the key stale so mounted consumers or the next read
can load the canonical server result. Failed mutations do not invalidate known-good cached reads;
when rollback is disabled, only cache entries changed optimistically are marked stale so the next
read loads canonical data.
Use invalidate: { targets: [[apiClient.products.get, input]], refetch: true } to
refresh a previously cached API read in the background after a successful mutation. Farm reuses
that read's resolved route, input, key, cache policy settings, retry settings, and deadline, forcing
a fresh read instead of replaying the mutation. Duplicate targets refresh once. Explicit cache
keys work too; expired/missing entries or keys populated outside the API client are only invalidated
because they have no retained API read to replay.
Background refetch resolves current header defaults, does not reuse the old caller's abort signal or per-call callbacks, and never repeats optimistic updates or mutation invalidations. Shared lifecycle hooks still observe it. A failed refetch leaves the entry stale and does not fail the already successful mutation. Read recipes expire or are removed with their cache entries.
Cross-tab invalidation
By default, invalidations stay inside the tab that produced them: a mutation in one tab marks
keys stale there, while a second tab keeps its cached reads until its own focus or reconnect
refresh fires. Opt into same-origin cross-tab propagation with
enableCrossTabCacheInvalidation:
"use client";
import { enableCrossTabCacheInvalidation } from "@farm.js/core/client";
// Call once during client startup; returns a disposer.
enableCrossTabCacheInvalidation();Invalidations from mutations, server functions, and explicit cache invalidation are posted to a
BroadcastChannel and applied in every other tab through the normal invalidation path, so
mounted stale queries there refetch on their own. Only invalidation keys cross the channel,
never cached data or responses, which leaves private and credentialed cache scoping untouched.
Each tab refetches through its own credentials.
The call is idempotent, safe during server rendering, and a no-op in environments without
BroadcastChannel. Pass channelName to isolate multiple Farm apps served from one origin;
enabling two different channel names in the same tab is an error.
Result shape
App-route and integration callers both expose data and error, so callers can branch on
result.error. App-route results also include a typed cache key; integration results retain
their own error contract and do not include that cache key.
const result = await apiClient.hello.post({
body: {
name: "Ada",
},
});
if (result.error) {
console.error(result.error.status);
return;
}
console.log(result.data.message);This makes client components easier to write because failed responses do not need to be caught with try/catch unless you want that behavior.
For app routes, if an HTTP error body is malformed, Farm still returns an http_error with the real status and
Response; the decoding failure is available as error.cause.
Server callers
Import api from the same shared module. No endpoint imports, second factory, or request-bound
instance are needed:
import { api } from "@/lib/api";
export default async function Page() {
const result = await api.hello.post({ body: { name: "Ada" } });
if (result.error) throw result.error;
return <p>{result.data?.message}</p>;
}api resolves the current request and that app's registered routes at call time. It works in Farm
server pages, queries, actions, and API handlers in development and the default universal
production runtime. Constructing the pair at module scope is safe. Calling api in the browser
or outside an active Farm request throws an actionable error; it never silently falls back to HTTP.
The local caller uses the app's server API mount, including custom base paths. A public
baseURL pointing at another origin affects apiClient, not local dispatch. The current
request's Cookie, Authorization, and Accept-Language headers are inherited; shared options
and per-call headers can override them. Other headers are not implicitly forwarded. Do not place
private tokens in the shared module: read them in server code and pass them per call where needed.
Local calls use the same route matching, params, input/output validation, endpoint middleware,
body limits, and response decoding as HTTP routes. They still serialize request/response data;
they avoid a network round trip, not all serialization. Request cancellation is inherited.
Opt-in caches and in-flight requests are isolated by request and credentials, never shared between
users. Dynamic $params() scopes work identically for both callers.
Direct-call boundaries
api is a direct endpoint caller, not a replay of the full HTTP request pipeline. It does not
run path-level HTTP middleware, redirects/rewrites, plugin request/response lifecycle hooks, or
deployment-layer checks. Put authorization and other rules required for both transports in the
endpoint's middleware. Use apiClient when an operation must go through HTTP middleware.
Response cookies and headers from a local endpoint do not automatically become headers on the
outer page response. Post-response work scheduled by an endpoint uses the enclosing request's
lifecycle. Integrations remain available under api.integrations and apiClient.integrations,
using their existing integration-dispatch semantics.
Existing factories
createAPIClient() remains supported for an HTTP-only caller, including standalone scripts with
an absolute baseURL. createServerAPIClient({ hello: { get: GET } }) remains supported for
explicit endpoint-function maps in server-only modules. It returns the supplied functions and
their raw results; passing only { request } does not discover routes. Prefer the paired
factory for a shared module and a consistent { data, error, key } app-route result.
The HTTP-only factory retains the same route and integration inference:
import { createAPIClient } from "@farm.js/core/client";
import type { APIRouter } from "./api.generated";
import type { AppIntegrations } from "./integrations";
const http = createAPIClient<APIRouter, AppIntegrations>();
// Integration callers are under http.integrations.<configured namespace>.
const routesOnly = createAPIClient<APIRouter>({ integrations: false });
// Only the app's route tree is exposed; no reserved integration caller namespace.integrations: false disables only the caller namespace, not the configured providers or their
HTTP routes. These overloads are available from the published @farm.js/core/client entry;
there is no need to import framework internals or cast the options.
Integration callers
Use the same shared module for file routes, plugin routes, and configured integrations:
import { createApiClients } from "@farm.js/core/client";
import { apiRoutes, type APIRouter } from "./api.generated";
import type { AppIntegrations } from "./integrations";
export const { api, apiClient } = createApiClients<APIRouter, AppIntegrations>({
routes: apiRoutes,
});Export AppIntegrations = typeof appIntegrations from the server-only module containing the
registry passed to farm.config.ts's integrations field. Import only its type here, not the
registry value or provider SDKs. The type describes existing integrations; it does not register
them. Farm supplies their caller metadata at runtime.
File and plugin routes keep their generated paths, such as apiClient.hello.post(...).
Integrations live under the reserved .integrations namespace on both callers. For example, with
the billing integration from the custom integration guide:
// Browser code
const checkout = await apiClient.integrations.billing.checkout.post({
body: { priceId: "price_123" },
});
// Server code
const serverCheckout = await api.integrations.billing.checkout.post({
body: { priceId: "price_123" },
});Integration-specific defaults belong in integrations: { data, headers, ... } in the shared
factory options. Only put browser-safe values there. Set integrations: false if the app does
not need the reserved namespace.
The shared factory does not change integration execution semantics. Integration calls return
{ data, error }, not the app-route { data, error, key } cache contract. Server integration
calls dispatch to a registered handler when possible and can fall back to HTTP when local
dispatch is unavailable. App-route api calls instead require an active Farm server request and
never fall back to HTTP. Operations marked isServer: true remain available only through
api.integrations, not apiClient.integrations.
Integration-only callers
createIntegrations<AppIntegrations>() remains supported and is not deprecated. Existing apps
do not need to migrate. Use it when only integration callers are needed, or when you prefer to
keep them separate from app-route callers. It returns integration namespaces directly:
apiClient.billing.checkout.post(...) and api.billing.checkout.post(...).
With createApiClients, those same calls need the
.integrations segment. Choose one setup for the shared module; do not create both pairs for
the same integrations. Switching factories requires updating the namespace and moving shared
integration defaults into the integrations option; it is not a drop-in rename.
The paired factory discovers configured integrations; it does not accept the integration-only
factory's explicit source map or separate server-options argument.
Options are another reason to choose the separate factory. Shared baseURL, headers,
credentials, and data defaults work with either setup: put them under integrations when
using createApiClients(). Keep createIntegrations() when you want setup-level request or
forwardHeaders, separate server defaults, or explicit source maps. The paired factory's
server integration calls still accept per-call overrides. See the options comparison and
request-scoped example. Keep request-bound callers
and private server options in server-only code, not in the shared browser module.
For deliberately separate modules, use createApiClients<APIRouter>({ routes: apiRoutes, integrations: false }) for app routes and createIntegrations<AppIntegrations>() for integration
callers. Disabling the paired factory's namespace does not unregister integrations or their
HTTP routes; it only leaves integration access to the separate caller module.
Server Function Form Actions
createServerFn pairs with useServerFn when a mutation is naturally a form action. Use optimistic to show the next UI state immediately, then let the server result replace it when the action completes.
Both hooks accept retry with the API client's shape, { count, delay }, where delay is a
fixed wait or an (attempt) => ms backoff starting at attempt 1. The default remains a single
attempt. Retries rerun the whole submission against the server function, so keep retried handlers
idempotent. reset() stops a waiting retry: the submission rejects with its last error and no
further attempts start.
For both useServerFn and useAction, reset() restores initialResult (or null), clears
the error, and returns the hook to idle. It does not cancel server work: promises from earlier
submissions still resolve or reject, but their completions no longer change hook state or invoke
completion callbacks. Requests started after the reset have their own pending count, so older
work cannot clear their pending state.
The onSuccess, onError, and onSettled options on these hooks are notification callbacks.
Thrown errors and rejected promises from them are reported through reportError, or the console
when unavailable, without replacing the server result/error or changing the pending count.
Callbacks are not awaited; put required work in the server function or explicitly await it after
the call. If onSuccess or onError synchronously resets the hook or starts another submission,
the superseded submission's onSettled callback is skipped. throwOnFormError still controls
whether formAction rethrows the server error, not a notification error.
import { createServerFn } from "@farm.js/core/server-fn";
import { z } from "zod";
export const addTodo = createServerFn({
input: z.object({
title: z.string().min(1),
}),
output: z.object({
todos: z.array(
z.object({
id: z.string(),
title: z.string(),
}),
),
}),
async handler({ input, signal }) {
signal.throwIfAborted();
return {
todos: await db.todo.create({ data: input }),
};
},
});input validates values before the handler runs. An optional output schema validates the
resolved handler result before it crosses the server-function boundary. Its parsed type becomes
the function's return type, and schema transforms are supported:
const PublicUser = z.object({
id: z.string(),
email: z.string().email(),
});
export const getUser = createServerFn({
input: z.object({ id: z.string() }),
output: PublicUser,
async handler({ input }) {
// PublicUser strips passwordHash before this result can reach the browser.
return db.user.findUniqueOrThrow({ where: { id: input.id } });
},
});Output parsing also runs for direct server calls, form actions, and browser calls. Invalid results reject the function just like invalid input. Keep the output contract narrow for private data; do not rely on TypeScript alone to prevent an extra database field from being returned at runtime.
Composable middleware
Use createServerMiddleware for server-only behavior shared by several functions, such as session
loading, authorization, transactions, rate limits, and auditing. Middleware can depend on other
middleware, and every context value is inferred by functions that install it.
import { createServerFn, createServerMiddleware } from "@farm.js/core/server-fn";
const withSession = createServerMiddleware({
async handler({ request, next }) {
if (!request) throw new Error("A request is required");
const session = await getSession(request);
if (!session.user) throw new UnauthorizedError();
return next({ context: { session } });
},
});
const withTransaction = createServerMiddleware({
middleware: [withSession],
async handler({ context, next }) {
return db.transaction((tx) => next({ context: { tx } }));
},
});
export const renameProject = createServerFn({
middleware: [withTransaction],
input: z.object({ projectId: z.string(), name: z.string().min(1) }),
async handler({ input, context }) {
// context.session and context.tx are both typed.
await requireProjectEditor(context.session, input.projectId);
return context.tx.project.update({
where: { id: input.projectId },
data: { name: input.name },
});
},
});Dependencies run first and are de-duplicated by middleware identity. For
middleware: [withTransaction, withAudit], a shared withSession dependency runs once. The chain
uses onion ordering: code before await next() runs from outer to inner, and code after it unwinds
from inner to outer.
Every middleware must call next() exactly once and return its result. Throw to reject a request;
middleware cannot silently skip the handler. Input validation finishes before the chain starts,
while output validation runs after the whole chain unwinds. Context is created on the server,
shallowly frozen, and never accepted from the browser.
Keep shared authentication in middleware, but still perform resource-specific authorization where the resource is loaded. Derive identities, roles, tenant IDs, and rate-limit keys from the trusted request or server state, never from unvalidated client fields. Middleware errors use the same sanitized server-action error boundary as handler errors.
"use client";
import { useServerFn } from "@farm.js/core/server-fn/client";
import { addTodo } from "../actions/todos";
export function TodoForm() {
const action = useServerFn(addTodo, {
initialResult: { todos: [] },
rollbackOnError: true,
optimistic({ current, formData }) {
return {
todos: [
...(current?.todos ?? []),
{ id: "draft", title: String(formData?.get("title") ?? "") },
],
};
},
});
return (
<form action={action.formAction}>
<input name="title" />
<button disabled={action.pending}>Add</button>
</form>
);
}The optimistic callback receives the raw input, formData for form submissions, and the current result. Return undefined when a submission should not change the optimistic result. Use rollbackOnError for reversible UI state; keep authorization and validation on the server function itself.
For useServerFn and useAction, current includes earlier optimistic transitions from the same
React batch. Three submissions that each return (current ?? 0) + 1 from an initial result of 0
therefore show 3, even before React rerenders. The callback runs once per submission, including
in Strict Mode. A failed latest submission with rollbackOnError restores the result immediately
before that submission, including a previous optimistic value; a same-batch reset() restores
the initial snapshot for subsequent submissions. If an optimistic callback itself resets or starts
new work, the older transition cannot replace the reset or newer result. Reset still does not
cancel execution of an already-started submission.
When the function is called from the browser, request is the underlying Web Request and signal aborts with that request. A direct call made while rendering can inherit the current render request; a background or direct call outside request scope has no request and receives a stable, non-aborted signal. The same values are available to middleware. Pass signal to database or network clients that support cancellation.
Farm validates action origin metadata, accepted form/RSC content types, action ID shape, and request size before decoding an action. Browser calls use same-origin credentials and refuse redirects. Unexpected thrown values are logged on the server but become a generic ServerActionError in the browser, so secrets and stack traces are not serialized.
Typed server function errors
Declare expected failures next to the input contract. error accepts only declared codes, validates
the public payload, and preserves the code, status, and data across the RSC action transport:
export const updateProduct = createServerFn({
input: updateProductSchema,
errors: {
NOT_FOUND: {
status: 404,
data: z.object({ id: z.string() }),
},
},
handler({ input, error }) {
const product = findProduct(input.id);
if (!product) {
return error("NOT_FOUND", { id: input.id });
}
return product;
},
});useServerFn, useMutation, and useFetcher infer the declared error union. Narrow by name and
code to recover the exact payload:
const update = useServerFn(updateProduct);
if (update.error?.name === "ServerFnFailure" && update.error.code === "NOT_FOUND") {
// id is inferred as string.
showMissingProduct(update.error.data.id);
}Add an optional message only when it is safe to display publicly. Declared error data schemas must
support synchronous parse() or safeParse() because error() throws immediately. Hydrated calls
carry status inside the Flight error envelope; progressive form submissions also use it as the HTTP
status. Unexpected exceptions remain sanitized as a generic ServerActionError, without their
message, stack, or custom properties.
Action references identify which function to execute; they are not authorization tokens. Check authentication, roles, tenant ownership, and resource access inside every action that reads or changes private data.
Production notes
- Keep generated API types committed or generated during CI.
- Prefer typed body/query schemas for mutations.
- Use server callers for secrets, auth cookies, and internal-only provider actions.
- Use invalidation after mutations that change cached route data.
- Keep optimistic updates scoped to UI state you can confidently roll back.
- Keep
serverActions.allowedOriginsnarrow and use API routes for intentionally cross-origin callers. - Return typed expected failures; reserve thrown errors for unexpected failures.
- Add narrow output schemas to functions that return private database records.