Why I Built FarmJS: The Framework Gap

I started building FarmJS because I kept wanting the same things in the same application: familiar routing, control over rendering, typed calls between the browser and server, and a sensible place to connect the services a product actually needs.

Maintaining Better Auth made those gaps especially visible to me. Working across frameworks, I kept seeing inconsistencies in server and client behavior, request lifecycles, and how session state moves between them. That experience shaped what I wanted FarmJS to do better.

I had used Next.js, experimented with Remix, followed TanStack Start, and spent a lot of time reading framework source code and RSC internals. There was plenty to learn from each of them. But I kept coming back to the work between the features.

A user signs in. Middleware resolves their organization. A page reads their subscription. A mutation changes something. The cache needs to know. A webhook arrives later and changes it again. Somewhere in there, an email goes out and a background job starts.

That is an ordinary product flow. I wanted the framework to help make it coherent.

At some point, the glue code starts needing its own architecture diagram.

The first version of this post was a wish list. FarmJS has grown since then. It now has typed routing and APIs, middleware context, plugins, product integrations, multiple renderers, and experiments in both server rendering and client updates.

This is why I built it, and how those pieces fit together.

The Gap I Kept Running Into

Frameworks make useful decisions for us. That is a large part of their value. But choosing a router often also means choosing a rendering philosophy, a data model, a build pipeline, and an integration story.

I wanted more room to make those decisions independently.

The ecosystem has also moved since I started this draft. Next.js's App Router defaults pages and layouts to Server Components and lets you compose Client Components within them. TanStack Start provides full-document SSR, streaming, server functions, middleware, and experimental RSC. React Router also has an experimental RSC path. Their current Next.js, TanStack Start, and React Router documentation describes those choices.

So the gap I care about cannot be reduced to which framework has RSC.

For me, it is whether the whole application fits together: route definitions, request context, server calls, caching, provider integrations, development tooling, and deployment. I wanted to explore a particular combination of those ideas, with configuration and extension points I could understand by reading the app.

That became FarmJS.

Familiar Routes, Explicit Choices

I like app-directory routing. page.tsx, layout.tsx, dynamic segments, route groups, loading states, and error boundaries are useful conventions. FarmJS keeps that shape and generates route types around it.

The operational decisions live in farm.config.ts: rendering rules, deployment, integrations, plugins, storage, docs, and other framework behavior.

For example:

import { defineConfig } from '@farm.js/core'

export default defineConfig({
  deploy: {
    target: 'node',
  },
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { swr: 300 },
    '/dashboard/**': { render: 'dynamic' },
  },
})

That is a small config, but it expresses a useful application: a prerendered home page, blog content with a refresh window, and a dashboard rendered dynamically.

The distinction matters. RSC describes a component and transport model; SSR, static generation, and revalidation describe when rendering happens. They are related choices, but they are not interchangeable modes.

FarmJS supports dynamic server rendering, static generation, and ISR-style revalidation. React applications can opt into the experimental RSC pipeline. Experimental partial prerendering adds cached shells with dynamic sections behind Suspense; it requires both the framework flag and route opt-in.

Client components can still be server-rendered before becoming interactive. "use client" does not mean "never run this on the server." Deferred hydration lets a route wait for visibility, idle time, or interaction before loading its interactive code.

The point is to let a content page, an account dashboard, and an interactive tool make different choices inside the same application. The rendering guide covers those boundaries.

Vite for Development, Nitro for Production Output

FarmJS uses Vite for its development and bundling pipeline and Nitro for the final server output.

That gives me an existing ecosystem to build on: module transforms, HMR, renderer plugins, and deployment presets. There is still framework work to do around route discovery, server/client boundaries, and output generation, but the bundler does not need to become another project of its own.

The current deployment targets include Vercel, Cloudflare Pages, Netlify, and self-hosted Node. Direct Nitro presets remain available for other output shapes, including Workers configurations.

Runtime choice still has consequences. A Node-only dependency does not become portable because a config value changed. What I want is a visible deployment contract, with platform-specific behavior expressed in the build.

The Server and Browser Should Share a Contract

An HTTP route is useful beyond your own frontend. A mobile app, webhook sender, or external client should be able to call it normally.

Inside the application, I also want types to follow that route.

FarmJS generates route metadata and types, then provides a paired server caller and browser client:

// src/lib/api.ts
import { createApiClients } from '@farm.js/core/client'
import { apiRoutes, type APIRouter } from './api.generated'

export const { api, apiClient } = createApiClients<APIRouter>({
  routes: apiRoutes,
})

Server code uses api; browser code uses apiClient. Given an application endpoint at /api/users, the browser call reads like this:

import { apiClient } from './api'

const { data, error, key } = await apiClient.users.get()

The endpoint defines the actual input and output types. The server caller dispatches app routes locally; the browser client uses HTTP. The shared setup imports generated paths and types, keeping handler implementations and credentials on the server.

The API client also has cache policies, invalidation, retries, optimistic updates, cancellation, deadlines, and lifecycle callbacks. Integrations can join the same setup through typed namespaces.

There are other useful server boundaries too. FarmJS has server functions, Server Actions for mutations and forms, and server queries for reads with structured cache keys. Queries can participate in prefetching, deduplication, stale-while-revalidate, and shared invalidation. Browser calls to server functions and queries need the server-reference transform enabled; their implementations must stay out of the browser bundle.

I want the choice to follow the job: an HTTP endpoint for a public contract, a server function for application logic, and a query when the read needs a cache lifecycle.

The URL Is Part of That Contract

Search terms, filters, pagination, and selected tabs often belong in the URL. They should survive refreshes and be shareable.

FarmJS provides loadSearchParams() and loadRouteParams() for server parsing, plus useQueryState() and useQueryStates() for client updates. Parsers define the values and defaults. Generated route types help with known route paths; the query parsers handle the values carried by the URL.

For local UI state that does not belong in the URL, createStore() provides a small store with field selectors, updates, and subscriptions. A sidebar preference should not need the same machinery as a server query.

These are different kinds of state. Giving each one a clear home makes an app easier to understand.

Middleware Should Be Able to Talk to the Page

If middleware has already resolved a session or tenant, I want the page to use that result. Repeating the lookup, or encoding an internal object into headers just to decode it later, adds work and hides the data flow.

FarmJS has request-scoped context for this. Middleware can put server-only values in context.set() or ctx.locals.set(). A layout, page, or nested Server Component can read them with getMiddlewareContext().

There is also a separate ctx.data.set() channel for serializable, client-safe values that can travel with page props. The distinction matters: a display name and a full session object do not have the same destination.

Middleware can live beside the routes it protects or be registered in config. The chain API supports ordinary application logic through .use(), .when(), rate limits, redirects, and rewrites. There is also a request-first named export that receives a standard Web Request.

That is the kind of framework behavior I wanted: compute something once, keep it scoped to the request, and make it available where it is needed. The middleware guide shows both APIs and their data boundaries.

Integrations Are Part of the Application

Auth, billing, email, and background jobs affect more than one route. They own configuration, endpoints, lifecycle work, and sometimes database models or UI providers.

FarmJS gives them a public integration contract.

An integration can register its routes, typed callers, middleware, models, and lifecycle behavior together. The application chooses its namespace, and the rest of the app calls that configured capability.

For example, an application can keep ownership of its Stripe SDK instance:

import { defineConfig } from '@farm.js/core'
import { stripe } from '@farm.js/stripe'
import { stripeClient } from './src/lib/stripe-client'

export default defineConfig({
  integrations: {
    billing: stripe({
      instance: stripeClient,
      webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
    }),
  },
})

Here stripeClient is an application-owned instance created from the stripe package in server-only code. Farm supplies the framework integration around it. That leaves SDK configuration and direct provider access available to the application.

The current integration surface includes:

  • Authentication: built-in Farm Auth, Better Auth, Auth.js, Auth0, Clerk, WorkOS, and Supabase.
  • Billing: Stripe, Polar, and Autumn, with provider-specific checkout, portal, webhook, and entitlement capabilities.
  • Email and jobs: Resend, plus background-job integrations for Inngest and Trigger.dev.
  • AI and agents: AI SDK integration, Eve, and Cloudflare Agents.
  • Other product infrastructure: Unkey, database and ORM connections, and UI registry integration.

These providers have different capabilities and ownership models. An abstraction should make them easier to connect while leaving those differences visible.

The dedicated integration packages, such as @farm.js/stripe and @farm.js/better-auth, are the current authoring surface. The older @farm.js/integrations/* paths remain compatibility exports.

This is a large part of what I mean by a framework for product-integrated applications. The signup-to-checkout-to-webhook flow should have a place in the framework's design.

Tunnels: Let the Outside World Reach Localhost

Once the app connects to real services, localhost becomes an inconvenient address. A payment provider needs somewhere to send webhooks. An OAuth provider needs a callback URL. A teammate needs to try the flow, and I want to open it on my phone.

Unfortunately, Stripe has never accepted "works on my machine" as a webhook URL.

FarmJS has built-in preview tunnels for this. Start the development server:

farm dev --port 3000

Then, in another terminal, expose it through a named public preview:

farm preview --port 3000 --name checkout-test

The public URL forwards requests to the app already running locally. That means a real webhook can reach the local handler, exercise middleware, and show up in the request logs while I work on the integration. The same URL works for OAuth callbacks, mobile checks, and sharing a local branch.

Underneath that command, @farm.js/tunnel supplies a Rust-native agent. It opens an outbound WebSocket connection to the preview relay, which carries HTTP requests and responses over that connection. The preview tooling also has a TypeScript reference agent and a compatibility polling path.

The session follows the local app's lifecycle: when the target stops, the tunnel closes and its public URL is invalidated. Remote disconnects can also cancel the corresponding local request. The current protocol buffers request and response bodies; the relay's WebSocket connection does not imply support for forwarding arbitrary application WebSockets or Vite HMR.

This is a temporary development session. Production deployment still goes through the normal build and deployment path. I like having both workflows close to the application, because testing an integration should not require deploying every edit.

Plugins Give the Ecosystem Somewhere to Grow

Integrations connect product services. Plugins extend how the framework operates.

FarmJS exposes lifecycle hooks for configuration, startup, requests, routing, rendering, builds, development, and shutdown. Client plugins can observe browser hydration, navigation, errors, and cleanup.

The plugin ecosystem now includes tools for several parts of an application:

  • Development and inspection: DevTools, bundle analysis, live accessibility and performance hints, and MSW request mocking.
  • Content and presentation: typed content collections, static-page search, and StyleX.
  • Browser behavior: PWA support, external script loading, Partytown, and WebAssembly.
  • Extensions and visibility: browser module federation, Sentry, and experimental WebMCP tools.

The details are where those tools earn their place. The bundle analyzer connects an emitted page to the JavaScript and CSS it actually loads, produces a visual report, and can enforce size limits in CI. The Hints plugin reports accessibility issues, layout shifts, hydration and navigation timing, and third-party script problems while the app is running.

That gives the performance work a feedback loop. After changing a component or adding a service, I can inspect what reached the browser and what got slower. A compiler flag is more useful when I can see its effect on a real page.

A plugin author should have a documented point to attach request tracing or inspect a build. That is more sustainable than asking every integration to reach into private framework internals.

Layers handle a related need at the application level. They let teams share routes, layouts, middleware, plugins, integrations, and config through local directories or packages. The consuming project has the final override.

A reusable company foundation should be something an app can consume and evolve. Copy-paste has had a remarkably long career as a package manager.

The Experimental AOT React Compiler

This is one of the newer parts of FarmJS that I am most interested in.

An ordinary React state update can rerun a component, create another element tree, and reconcile it against the previous result. That flexibility is useful. But some components have a structure the compiler can understand ahead of time.

Take a counter whose only changing output is a text value. The location of that text and its dependency on count are knowable before the user clicks anything.

The counter changed from 4 to 5. Ideally, that does not require an all-hands meeting.

FarmJS's experimental ahead-of-time compiler prepares those relationships during compilation. For eligible components, it emits local state cells and binding metadata. After mount, supported updates patch the affected DOM bindings directly.

You enable it on the React renderer:

import { defineConfig } from '@farm.js/core'
import { react } from '@farm.js/react'

export default defineConfig({
  renderer: react({
    experimental: {
      compiler: true,
    },
  }),
})

Then the component can still look like ordinary React:

'use client'

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      Count: {count}
    </button>
  )
}

The current implementation is a Babel AST transform in Farm's Vite pipeline. This is Farm's own compiler experiment, separate from the React team's React Compiler.

What It Optimizes

The supported surface has grown beyond text counters. It includes eligible text, attribute, and style bindings, primitive-prop updates, selected conditional structures, and supported keyed-list operations. Some structures remain React-owned boundaries inside a compiled component.

The important word is eligible. The compiler needs to establish that an update has the supported shape before choosing a direct update path.

React still owns initial rendering, SSR, hydration, event handling, the surrounding component tree, and unmounting. Parent updates still enter through React. Unsupported components stay on normal React.

The default inference mode searches for supported components. Annotation mode lets you choose them explicitly with "use compiler"; "use no compiler" keeps a component on React. Diagnostics can explain why a component fell back, and a build report can show coverage.

The runtime imports only the compiler features that a transformed module needs, allowing unused features to be tree-shaken. An untransformed module does not receive a compiler runtime import.

Why the Fallback Matters

A fast update is useful only if the application still behaves correctly.

List identity, focus, text selection, queued state updates, hydration, and component lifecycles all matter. If an optimization cannot establish the supported ownership and update rules, it needs to leave that work with React.

The compiler documentation describes the supported cases and limits. There are also compiler benchmarks and a starter with an AOT-versus-React comparison.

I care about measuring the same interaction with the compiler enabled and disabled, including startup and bundle cost. A faster keyed-list operation does not establish that every application is faster.

The experiment is disabled by default and specific to Farm's React renderer. It is a way to explore how much update work can move out of the runtime while keeping ordinary React as the foundation.

Hydrating Only the Interactive Parts

The AOT compiler addresses browser updates after mount. There is also an earlier question: how much of the page needs to become a browser application in the first place?

Isolated client hydration is an opt-in path for React apps without RSC. Eligible "use client" leaves can hydrate as independent roots while the server-owned layout stays out of their browser graph. An analysis mode reports candidates before changing behavior.

Independent roots cannot inherit arbitrary shared React context. The experiment therefore has eligibility and graph-cost limits, with route-wide hydration retained for unsupported shapes it can identify statically. It is separate from RSC, and the RSC transport takes precedence when enabled. The configuration guide explains the remaining runtime limits.

Strata: A Different Path for Static Content

Then there is the server side. A long article, a documentation page, or a read-only product description can contain a lot of markup with very little interactive behavior.

Strata is the Rust-powered renderer behind Farm's optimized static boundaries. It turns eligible content into deterministic HTML inside a React-owned boundary. React continues to own composition and the interactive components around it.

In FarmJS, the integration can select eligible regions automatically:

import { defineConfig } from '@farm.js/core'

export default defineConfig({
  experimental: {
    serverComponents: true,
    optimizedBoundary: true,
  },
})

The application still authors ordinary JSX. Farm checks for sufficiently large, server-only regions made of supported host elements, then uses Strata where that narrower representation fits. Client Components, event handlers, refs, and unsupported shapes keep their React rendering path.

That is the part I find interesting: the framework can choose a representation based on what the content needs. A static article body and a stateful editor can share a page without needing identical treatment internally.

The current Farm integration uses a native Node binding. Strata handles eligible server output, the AOT compiler handles eligible updates in the browser, and isolated hydration controls which client leaves get their own roots. They address different costs in the same application.

The Framework Can Outlive a Renderer Choice

FarmJS started with React, and React remains the default. There are now beta adapters for Preact, Solid, Vue, and Svelte.

The renderer owns component compilation, rendering, and hydration. Farm keeps route discovery, APIs, middleware, integrations, caching, and deployment under a shared framework contract.

That does not mean every React feature becomes available in every renderer. RSC and the AOT compiler are React-specific. Some integration UI and documentation surfaces remain React-oriented, and streaming support differs by adapter. The renderer feature matrix makes those differences explicit.

What I want to preserve is the application infrastructure. A different UI runtime should not require reinventing the way a product connects to its server and services.

The Supporting Pieces Make It Feel Like a Framework

Compilers are interesting, but a framework also needs to make ordinary work easier.

Caching and storage. Farm has shared cache keys, tag and path invalidation, ISR, and a Redis cache adapter for shared deployments. Named KV storage mounts can use backends such as SQLite, Redis, or S3. For relational models, the application can use @farming-labs/orm or another ORM it already owns. KV mounts, application models, and integration-owned schemas have distinct jobs, with database and ORM connections tying the integration side into the app.

Persistent reads and optimistic UI. Server queries and API clients support cached reads, optimistic updates, and reconnect refresh. Browser caching is in memory by default; a client cache adapter can persist explicitly allowed reads. This is not a durable offline mutation queue or a general sync engine. The API client guide spells out the persistence policy.

Content and localization. Markdown and MDX routes, image optimization, fonts, metadata, sitemaps, robots output, themes, and internationalization are part of the framework surface. These are things products need even when the interesting engineering is elsewhere.

Work after the request. Cron configuration connects schedules to ordinary API routes, while after() supports post-response work. Durable jobs belong with an appropriate runtime such as Inngest or Trigger.dev; returning an HTTP response is not a durability guarantee.

Development and operations. OpenAPI output, test helpers, observability, and deployment controls help inspect the system. DevTools exposes routes, runtime configuration, and diagnostics. Together with preview tunnels and build reports, that gives the application a way to explain what it is doing while I work on it.

Each of these becomes more useful when it connects to the same route, request, and configuration model.

Apps Need to Be Readable by Agents Too

A product page is no longer consumed only through a browser UI.

FarmJS gives app pages Markdown representations. A React page can be rendered and converted to Markdown, while a neighboring page.md can provide a curated version. Readers can request the .md URL or negotiate Markdown through Accept: text/markdown.

The built-in docs engine is powered by @farming-labs/docs. It brings documentation pages, navigation, search, and agent-facing endpoints into the application through the docs configuration. MCP and LLM-readable indexes make the documentation accessible through more than its visual interface. The docs engine guide shows how that shared package becomes part of a Farm app.

Recent work also adds an opt-in agent.jsonLd configuration for Schema.org site identity, alongside default canonical and Open Graph metadata. Giving an agent readable content is one part of the job; helping it identify the source is another.

On the runtime side, integrations connect applications to Eve or Cloudflare Agents while leaving those systems' SDKs and execution models intact. The experimental WebMCP plugin covers another surface: explicitly registered browser tools.

These solve different problems: making content readable, exposing application tools, and running agents. I want the framework to give each one a clear place. The Markdown guide and integration docs show what is available.

What I Am Still Trying to Prove

FarmJS has a much larger implementation now than the framework I described in the original draft. That also means more combinations to validate.

A feature working in development is only the beginning. It needs to behave correctly in production, preserve server/client boundaries, work with its supported renderer, and make its failures understandable.

The compiler needs more real applications testing its eligibility rules. The rendering experiments need evidence about total cost, including transfer and hydration. Integrations need to hold up through provider changes and production workflows. The framework needs to stay understandable as these capabilities grow.

Those are ongoing responsibilities, not things a feature list settles.

The reason I keep building FarmJS is still the same: I want the parts of a product to fit together without making every choice permanent. Familiar routes. Explicit configuration. Typed boundaries. Services that have a public place to connect. Experiments that can be adopted deliberately.

That is the framework gap I wanted to explore.

You can read the docs, browse the source and examples, or try the compiler starter:

pnpm create @farm.js/app@beta my-farm-app --template react-compiler --typescript
cd my-farm-app
pnpm dev

For a regular starting point, use --template basic. Contributions, concrete bug reports, and hard questions are welcome on GitHub or Telegram.

✨ Schedule a call ✨

Let's talk and discuss more about my project and my experience on farming the modern technology

Schedule