Client Plugin API
A Farm plugin can own server and browser behavior through one definePlugin() call. Add lifecycle hooks directly under client; Farm extracts those hooks into its generated browser runtime in development and production.
Application code does not mount a provider, initialize a second registry, or forward router events.
Define both sides
import { defineConfig, definePlugin } from "@farm.js/core";
const analytics = definePlugin({
name: "acme:analytics",
version: "1.0.0",
enforce: "post",
setup() {
return {
// This value stays on the server.
serverToken: process.env.ANALYTICS_TOKEN,
};
},
runtime: {
after({ response }) {
return response;
},
},
client: {
public: {
projectId: "storefront",
sampleRate: 0.25,
},
async setup({ plugin, public: config, router, isDev }) {
const { createAnalytics } = await import("@acme/analytics/browser");
const analytics = createAnalytics(config);
return {
analytics,
pluginName: plugin.name,
debug: isDev,
navigate: router.navigate.bind(router),
};
},
hydration: {
before({ state, mode }) {
state.analytics.event(`hydration:${mode}`);
},
after({ state, durationMs, recovered }) {
state.analytics.event("hydrated", { durationMs, recovered });
},
},
navigation: {
before({ state, to, signal }) {
if (!signal.aborted) state.analytics.event("before", { path: to.pathname });
},
loaded({ state, to }) {
state.analytics.event("loaded", { path: to.pathname });
},
resolved({ state, to }) {
state.analytics.event("resolved", { path: to.pathname });
},
rendered({ state, to, durationMs }) {
state.analytics.page(to.href, { durationMs });
},
error({ state, error, to }) {
state.analytics.error(error, { path: to.pathname });
},
},
error({ state, error, phase }) {
state.analytics.error(error, { phase });
},
close({ state, reason }) {
state.analytics.flush(reason);
},
},
});
export default defineConfig({
plugins: [analytics],
});The value returned by client.setup becomes typed state in every later client hook. The shape of client.public is inferred and available as read-only public data in every event.
Server setup state and client setup state are intentionally separate. Neither is merged into a global application object.
Browser boundary
Farm extracts only known properties under client into a generated browser module. A client hook does not close over the surrounding server module.
Everything a client hook needs should come from:
- the hook event, including
public,router, and route information - browser globals such as
windowanddocument - a dynamic import made inside a client hook
- state returned by
client.setup
client: {
public: { release: "2026.07" },
async setup({ public: config }) {
const { startReporter } = await import("./reporter.browser");
return startReporter(config.release);
},
navigation: {
rendered({ state, to }) {
state.page(to.pathname);
},
},
},Do not reference a server module variable from a client hook. Put intentionally public values in client.public, import browser dependencies inside a hook, and return reusable browser helpers from client.setup.
Lifecycle reference
| Hook | When it runs |
|---|---|
client.setup | Once when Farm starts the generated browser runtime. May return typed private client state. |
client.hydration.before | Immediately before Farm hydrates or initially renders a hydratable route. |
client.hydration.after | After React accepts the initial hydrate or render operation. |
client.navigation.before | After navigation blockers pass and before route data or code loads. |
client.navigation.loaded | After route data, route code, or server HTML is ready. |
client.navigation.resolved | After Farm updates history and commits the navigation render. |
client.navigation.rendered | After the browser receives two animation frames for the committed route. |
client.navigation.error | When loading or committing that navigation fails. |
client.error | For hook failures, browser errors, unhandled rejections, hydration failures, and navigation failures. |
client.performance | For supported browser PerformanceEntry values. Farm observes only when this hook exists. |
client.close | During page hide, HMR disposal, or explicit runtime cleanup. |
Hydration events include mode: "hydrate" | "render", the root container, timing, and whether Farm recovered. A server-only page still starts client plugins but does not emit hydration hooks when no browser render occurs.
Navigation events include:
id,from,to, andaction- an
AbortSignalaborted when a newer navigation supersedes the current one - matched route
patternandparamswhen available durationMsafter loading begins- loaded route data in
navigation.loaded
Farm skips later lifecycle hooks for an aborted navigation.
Shared event values
| Value | Meaning |
|---|---|
plugin | Stable plugin name and optional version. |
public | Read-only JSON-safe data declared as client.public. |
router | Active client router with navigate, optional prefetch, and navigation state. |
isDev / isProd | Current browser build mode. |
deploymentId | Farm deployment identifier when available. |
state | This plugin's client.setup result, available after setup. |
Ordering and failures
Client hooks use the parent plugin's enforce order:
preplugins- normal plugins
postplugins
Farm runs hooks serially in that order. Cleanup runs in reverse order. A failed hook does not stop sibling plugins or application rendering; Farm logs it and sends it to other client error hooks with a phase such as plugin:navigation.before.
Security boundary
The browser entry is an allowlisted extraction, not a serialization of the whole plugin.
- Farm copies only
setup,hydration,navigation,error,performance, andclosefromclient. - Server hooks, server setup state, request context, imports, and environment values are not copied.
- Only
client.publicdata crosses the server-to-browser boundary. - Public data must contain JSON-safe scalars, dense arrays, and plain objects.
- Farm rejects functions, symbols, bigint values, non-finite numbers, class instances, dates,
undefined, accessors, hidden properties, sparse arrays, and circular references. - Farm rejects unknown lifecycle fields, accessors, and native or bound hook functions.
- Every public value is visible to anyone who can load the application JavaScript.
Published plugins
A package exposes one ordinary plugin factory:
import { definePlugin } from "@farm.js/core";
export function analyticsPlugin(projectId: string) {
return definePlugin({
name: "acme:analytics",
client: {
public: { projectId },
async setup({ public: config }) {
const { createAnalytics } = await import("./analytics.browser.js");
return createAnalytics(config.projectId);
},
navigation: {
rendered({ state, to }) {
state.page(to.href);
},
},
},
});
}Use a modern JavaScript target when publishing inline lifecycle hooks so function source remains available to Farm's build extraction.
Good use cases
- navigation and Web Vitals instrumentation
- client error reporting and release metadata
- view-transition coordination
- browser capability detection
- devtools panels and HMR diagnostics
- route progress UI owned by framework tooling
- client cache observers shared by a server plugin
Product SDK providers and typed product endpoints still belong in an integration. Use a client plugin when behavior follows Farm's browser lifecycle across unrelated applications.
Production checklist
- Keep
client.setup,navigation.before, andnavigation.loadedshort. - Respect navigation cancellation for optional async work.
- Sample high-volume performance and navigation telemetry.
- Flush best-effort telemetry in
client.close, but do not make correctness depend on page-hide delivery. - Test hydration, SPA navigation, cancellation, failure isolation, cleanup, and a production preset build.
Continue with Create a Plugin for server lifecycle and packaging patterns.
Define typed hydration, navigation, error, performance, and cleanup hooks directly inside a Farm plugin.