I've been building Farm.js, a full-stack framework with an experimental ahead-of-time compiler for React (repo on GitHub). The short version: when the compiler can prove at build time exactly which DOM nodes a piece of state touches, it skips React's render cycle entirely for that update and writes to the DOM directly. When it can't prove it, the component stays on normal React. No new mental model, no different component syntax.
You can try everything in this article without installing anything. The playground runs the real framework in your browser via WebContainers: open it on StackBlitz.
This post is a deep dive into how the compiler works, with honest benchmark numbers (including the case where it does nothing), and then a tour of the rest of the framework.
The idea
Take the most ordinary component imaginable:
import { useState } from "react";
export function Counter(props: { initial: number }) {
const [count, setCount] = useState(props.initial);
return (
<button
className={count > 0 ? "active" : "idle"}
onClick={() => setCount((value) => value + 1)}
>
Count: {count}
</button>
);
}
When you click, React re-runs the function, builds a new element tree, diffs it against the old one, and commits the differences. For this component that machinery is pure overhead: there is exactly one host element, and count can only ever affect two things, the text content and the className.
The Farm compiler analyzes the component at build time and, when the structure is provable, emits a definition like this instead:
createCompiledComponentWithFeatures(
{
initialize: (props) => [props.initial],
render: (props, state) => (
<button className={state[0].get() > 0 ? "active" : "idle"}>
Count: {state[0].get()}
</button>
),
bindings: [
{
kind: "text",
path: [],
dependencies: [0],
read: (_props, state) => ["Count: ", state[0].get()],
},
{
kind: "attribute",
path: [],
dependencies: [0],
name: "className",
read: (_props, state) => (state[0].get() > 0 ? "active" : "idle"),
},
],
},
[],
);
The interesting part is bindings. Each binding says: this state cell feeds this text node or this attribute, here is how to compute the value. render still exists and React still owns the first mount, so hydration, refs, and devtools all see a normal component. But after mount, calling the setter walks the dependency graph and applies the affected bindings straight to the DOM. No render, no reconciliation, no commit phase. There's a runtime test in the repo that renders the compiled and baseline versions side by side and asserts the compiled one updates with zero React renders.
The second argument, the empty array, is the component's capability list. Components that need more machinery (keyed lists, conditional ranges, nested compiled components) get feature modules in that array, and modules that don't use a feature never import it, so the unused structural runtimes are tree-shaken out of the production bundle. The measured runtime premium for direct-bindings-only output is 73.6% smaller than shipping the complete runtime.
The compiler refuses to guess
This is the part I care most about. The compiler is intentionally conservative: it only transforms a component when it can prove there's one stable host element tree and every supported state value maps to known text or attribute targets. Anything it can't prove falls back to the original React component. From the docs, things that always stay on React include:
- unkeyed or index-keyed list rendering
- effects, or hooks other than the supported
useStateshape -
ref,dangerouslySetInnerHTML, JSX spreads - multiple or conditional returns
- props whose updates carry identity (objects, arrays, functions, elements)
- async components, setters called outside JSX event handlers
Fallback is silent by default, but you can turn on diagnostics:
[react-compiler] KeyedList: dynamic child structures require React reconciliation; using React.
And report: true writes .farm/react-compiler.json with exactly which components compiled and which fell back, with reasons and counts. So "is my hot component actually compiled" is a question with a checkable answer, not a vibe. You can also opt any component out with a "use no compiler" directive.
Enabling it is one flag on the renderer:
import { defineConfig } from "@farm.js/core";
import { react } from "@farm.js/react";
export default defineConfig({
renderer: react({
experimental: {
compiler: true,
},
}),
});
Full compiler docs, including the complete eligibility table, are at farmjs.dev/docs.
Numbers, including the boring ones
The benchmark is a js-framework-benchmark-style keyed table (create 1,000 rows, update every 10th label, advance selection, swap two rows, clear), measured on production builds driven by Playwright Chromium. Latency is click dispatch to the MutationObserver callback for the resulting DOM writes, so it isn't quantized to frame boundaries the way rAF timing is. The runner installs the published npm packages rather than linking the workspace, and it fails hard unless the compiler report proves the workload component actually compiled, so it can't silently degrade into baseline vs baseline.
| action | baseline p50 | compiled p50 | p50 speedup |
|---|---|---|---|
| create | 2.70ms | 2.30ms | 1.17x |
| update | 0.70ms | 0.70ms | 1.00x |
| select | 0.60ms | 0.50ms | 1.20x |
| swap | 3.40ms | 0.50ms | 6.80x |
| clear | 3.80ms | 0.70ms | 5.43x |
CPU work (script + style + layout) per full action cycle: baseline 18.71ms, compiled 9.05ms, about 2x less.
Notice update is 1.00x. Row label updates were already cheap in React, and the compiled path doesn't magically beat the DOM write itself. The wins come where reconciliation is the cost: swaps, clears, structural churn on keyed lists. Methodology, correctness controls, and raw per-sample data live in benchmarks/compiler in the repo if you want to pick it apart or rerun it.
The rest of the framework
The compiler is the flashy part, but I've spent most of the time on everything around it. Farm is a full-stack framework: Vite dev server, file-based app directory routing, streaming SSR, and Nitro production output. Here's what I think is worth showing.
Routes are typed, end to end
The app directory looks like what you already know (page.tsx, layout.tsx, loading.tsx, error.tsx, dynamic [param] and catch-all [[...slug]] segments). The difference is that Farm generates route types from it:
export type RoutePath =
| "/"
| "/about"
| `/repos/${string}/${string}`
| `/users/${string}`;
Link hrefs are checked against this union, so a typo'd internal link is a type error, and the types regenerate on dev start and whenever routes change.
Server functions with schemas, not just "use server"
Server actions take zod schemas for input and output, so the wire boundary is validated in both directions:
import { createServerFn } from "@farm.js/core/server-fn";
import { z } from "zod";
export const submitMessage = createServerFn({
input: z.object({
name: z.string().trim().min(1, "Name is required"),
message: z.string().trim().min(1, "Message is required"),
}),
output: z.object({ success: z.literal(true), message: publicMessageSchema }),
async handler({ input }) {
// runs on the server, even when called from a client component
},
});
On the client, useServerFn gives you formAction for progressive-enhancement forms plus optimistic updates with automatic rollback:
const action = useServerFn(submitMessage, {
optimistic({ formData }) {
return { success: true as const, message: { id: -1, /* ... */ } };
},
rollbackOnError: true,
});
<form action={action.formAction}>...</form>
And server queries are the read-side sibling, with cache keys, stale times, and server-side invalidation that reaches into client caches:
export const productQuery = createServerQuery({
input: z.object({ id: z.string() }),
key: ({ input }) => ["product", input.id],
staleTime: "30s",
async handler({ input }) { /* ... */ },
});
// in a mutation:
invalidate(["product", input.id]);
Integrations are typed capabilities, not SDK glue
This is the piece I haven't seen elsewhere. An integration (Stripe, Supabase, WorkOS, Polar, Unkey, and so on) mounts as a typed capability in config:
export const appIntegrations = {
billing: stripe({
products: stripeProducts,
instance: stripeInstance,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
async onWebhook(event) { /* typed webhook events */ },
}),
} as const;
Then four lines give your whole app a typed client for every mounted integration:
import { createIntegrations } from "@farm.js/core/client";
import type { AppIntegrations } from "./integrations";
export const { api, apiClient } = createIntegrations<AppIntegrations>();
From a client component, await apiClient.billing.checkout({ body: { productId } }) is fully typed. The same call from a server component stays in-process instead of making an HTTP round trip back into your own app. The vendor SDK instance never leaves server-only modules.
Rendering modes as directives
Instead of exporting config objects, a route can declare its rendering mode in one line:
"use ssg; 60";
export default function BlogPage() {
return <main>Blog</main>;
}
That's static generation with 60-second revalidation. use dynamic and use ppr; 60 (partial prerendering with a static shell and streamed dynamic holes) work the same way. The classic export const revalidate = 60 style works too.
Hydration islands with click replay
Client components can defer their own hydration:
"use client";
export const island = "interaction";
export function CopyButton({ value }: { value: string }) {
return <button onClick={() => navigator.clipboard.writeText(value)}>Copy</button>;
}
interaction means the component ships zero hydration work until the first click, and Farm replays that click after hydration so the user's first interaction isn't swallowed. There are also visible and idle strategies, with load as the compatibility default.
Small things that add up
-
URL state hooks, nuqs-style:
useQueryState('page', asInteger.withDefault(1))with history and throttling control. -
Chainable middleware:
middleware().use(...).redirect('/old', '/new').when(ctx => ctx.pathname.startsWith('/api'), ...)with matcher/exclude config, per-segment middleware supported. - Storage helpers for KV across local files, SQLite, Postgres, MySQL, Redis, and MongoDB from one import, and a pluggable shared cache adapter (Redis-backed ISR/PPR cache for multi-instance deploys).
-
Typed API routes with
createEndpoint+ zod, and a generated RPC-style client for them. -
farm preview: a public tunnel URL for your running local dev server, no build or deploy involved. -
farm doctorandfarm explain <path>: the second one tells you exactly how a URL maps to a route and which runtime serves it. -
A docs site in your app with
docs: { enabled: true }: markdown undersrc/app/docsbecomes a searchable docs site with llms.txt, sitemap, and robots handling out of the box. - Skew protection: when a deploy happens while a user has a tab open, stale clients get a structured 409 with a "refresh before trying again" contract instead of mystery failures.
Not just React
The compiler is React-specific, but the framework isn't. Renderers are one line in config, and Preact, Solid, Vue, and Svelte are first-class, with native bindings for each (useServerQuery in Solid and Vue, createServerQuery in Svelte, and so on). Same routes, same server functions, same integrations. Deployment targets cover Vercel, Cloudflare, Netlify, and plain Node, with any other Nitro preset accepted as pass-through.
Try it
The fastest path is the browser playground: StackBlitz. Locally:
pnpm create @farm.js/app@beta my-app
Everything is beta (0.1.0-beta.61 as I write this) and the compiler is explicitly experimental and opt-in. The fallback behavior is the contract I'm most committed to: if the compiler can't prove a component is safe to transform, you get exactly the React you wrote.
The website is farmjs.dev, docs at farmjs.dev/docs, and the repo is github.com/farming-labs/farm.js. I'd genuinely love feedback, especially skeptical feedback, on the compiler's eligibility model and the typed integration approach. If you break something in the playground, an issue with a repro makes my day.
farming-labs
/
farm.js
a framework for modern product integrated app
A framework for building fast, full-stack, product-integrated applications.
Farm.js combines Vite's instant development experience with a purpose-built React Server Components renderer, secure Server Actions, typed app-directory routing, and production-ready deployment output
Documentation · Examples · Quick Start
✨ Built for Full-Stack Products
Feature
What it gives you
⚛️ Custom RSC renderer
A purpose-built RSC, streaming SSR, and client hydration pipeline, with optional Rust-native rendering for eligible host-only regions through Strata.
🔄 Server Actions
Write mutations next to your components with
"use server", form actions, optional encrypted bound arguments, and configurable request security.
⚡ Blazingly fast development
Vite-powered startup, on-demand transforms, and fast HMR keep the feedback loop nearly instant.
🧭 Typed app-directory routing
Pages, layouts, route groups, dynamic segments, loading states, error boundaries, middleware, and generated route types.
🧩 Flexible rendering
Choose streaming SSR, static generation, ISR, PPR, or deferred hydration islands route by route.
🛠️ Full-stack primitives
Top comments (0)