Farm.js

Native Screens

Part of Mobile Apps: the tiers past the WebView, and how routes reach them.

Server-rendered native screens

A route in the analyzer's native tier (tiers) needs no JavaScript on the device, so it does not need React Native either. With export const mobile = { native: true } on the page, a config route with screen: "native" and no native file, or native.auto: "on", it is recorded in farm-routes.json as screen: "server", and the shell draws it with plain platform views:

shell ── GET /notes, Accept: application/vnd.farm.native+json ──▶ web server
                                                                   renders /notes as for a browser
shell ◀── { root: { type: "View", children: [...] } } ─────────── reads the HTML into a tree
  • Data and auth stay on the server. The server renders the page through its normal request path, middleware and guards included, with the WebViews' cookies. Async pages and server components work, since they run on the server as for the web page. iOS applies the cookie store's host, domain, path, Secure and expiry rules to each server-screen and image request.
  • Only the page. The tree is the page's own content: the layouts are web chrome, and the native shell draws the bar and tabs.
  • Styles map at build time. Tailwind classes resolve to native values from Tailwind's default theme (colors from oklch to sRGB, spacing, type, radius); dark: variants follow the system theme; text styles flow down as CSS inheritance does. The analyzer only lets a class through when the tree can resolve it.
  • The app's own CSS applies too. The server reads the stylesheets the page links, as the browser gets them (in production, the client CSS the build embedded), and cascades their rules: type, class, id, and attribute selectors, compounds, descendant, child, and sibling combinators, :first-child and :last-child, specificity and source order, :root variables with var(), and @media (prefers-color-scheme: dark). Inline style attributes read the same variables, then their fallback, so @farm.js/ui color tokens (var(--farm-color-muted, #6b7280)) draw natively and follow a dark :root value. Shorthands such as margin: 8px 0 4px and border: 1px solid var(--border) expand, and background: none or its minified 0 0 clears the color; :hover and :focus rules never match a static screen; body passes its text styles down and gives the screen its background. Rules in @layer are Tailwind's and left to the table above. Another @media, an unknown pseudo-class, or a declaration with no native style is reported in unsupported. The analyzer reads the plain .css files the route imports with the same rules, so a class only passes the native tier when the app's CSS styles it in a way the tree can draw.
  • Any doubt is web. The response lists what it could not express in unsupported; the shell then shows the route's web screen, as it does for a failed request or an answer that is not a tree, such as a redirect to sign in.

Drawn on Android: View (flex column or row, gap, padding, margins, width and height in px or 100%, flex-1, alignment, max-w-*), Text with inline runs (links, <strong>, <em>, <code>), Image, links and buttons as pressable boxes, and borders, radii, backgrounds, and opacity, and wrapping rows (flex-wrap: wrap). Not yet: percentages other than 100%, wrapping columns, absolute or sticky positioning, Sass and other non-CSS stylesheet sources in the analyzer, forms, and inputs that do anything. iOS draws server routes with UIKit (stack views, attributed labels with tappable links, image views), with the same fallback to the web screen; it is type-checked, and CI builds and launches the shell with a native home route. The smoke check requires the native content-ready log marker and rejects a fallback to the web screen.

A web server answers these requests only for an app with a mobile block, so a web-only app's server is unchanged.

examples/mobile sets native.auto: "on". Seven of its routes are server screens (/, /notes, /notes/[id], /about, /feed, /navigation/step/[n], /docs/[...slug]), and its pnpm smoke checks that each one's tree converts with nothing in unsupported against the production server. The routes with client-side state stay web.

Native screens

A native screen is declared by the file next to page.tsx (page.native.tsx, page.ios.tsx, page.android.tsx, page.kt, page.swift) or by a mobile.native.routes entry with screen: "native" or "platform". The generated Android project registers native screens by their module id in FarmNativeScreens, keyed like app/notes/[id].

Status: React Native screens run under farm dev --mobile on an Android emulator; production builds and real devices are not verified yet (below). A platform route is recorded in farm-routes.json and shown on device as a placeholder naming the module id: Farm does not build page.kt or page.swift yet. A config route that asks for screen: "native" with no file gets a generated port on Android when the page passes the analyzer and the emitter; otherwise it stays web and records requested: "native" with a warning that says why. An ejected project is where an app registers its own platform screens before they land.

The two engines, as designed:

  • react-native — the default native engine. A screen renders as a React Native surface on the new architecture (Hermes bytecode, JSI, Fabric), with one shared JS runtime warmed lazily after the first native screen paints, so an app whose start screen is web pays nothing at startup. Screens navigate by URL, never by component, so the navigator's rules always decide the next screen. React only.
  • platform — Kotlin/Compose on Android or Swift/SwiftUI on iOS, for screens that should run no JS at all. A platform screen adds its own native code and nothing else, and does not depend on the app's renderer.

Both tiers call the same server functions and route-data requests as the web client, so the data layer is shared. See Design notes for why native is opt-in rather than the default.

React Native screens

A page.native.tsx (or page.android.tsx) next to a route's page.tsx is built as a React Native screen for the Android shell. Treat this tier as experimental. Install React Native, its Metro config, and @farm.js/native as dev dependencies; a web build never resolves any of them:

pnpm add -D @farm.js/native react-native@^0.88 @react-native/metro-config@^0.88
pnpm add react@^19.2.8 react-dom@^19.2.8

@farm.js/native needs React 19.2.8 or later. React Native 0.88 declares a react@^19.3.0 peer (its renderer is built from React 19.3.0), so on 19.2.8 your package manager warns, but the mobile example runs its native screens on 19.2.8 with one React in the dev bundle. That is verified in dev and in a production APK on an emulator. Web pages and native screens bundle the app's one react, so keep react and react-dom on a single version.

What is verified, in examples/mobile under farm dev --mobile on an Android 15 emulator (SwiftShader software rendering, no GPU):

  • page.native.tsx and page.android.tsx screens render as React Native with data from API routes.
  • A push from a native screen to a web screen takes 120 to 134 ms, and the back gesture returns to the native screen. A web modal opened from a native screen closes on back. Tabs work.
  • Fast Refresh works.
  • Cold start takes 1.4 to 2.9 s, with 84 to 198 ms of main-thread startup work. The React Native runtime warms up 1.2 to 5.0 s after the first paint, loading its bundle from Metro. The first /map open takes 611 to 1413 ms (103 to 797 ms on later opens), the first /camera open 184 to 205 ms.

In a bytecode build (farm build --mobile --apk, a debug APK) against a local production server, /map opens as React Native with its stations. Not verified yet: real devices, so the emulator numbers say nothing about smoothness on a phone.

// src/lib/api.ts: the app's typed API client, shared by web pages and native screens
import { createApiClients } from "@farm.js/core/api/client";
import { apiRoutes, type APIRouter } from "./api.generated";

export const { api, apiClient } = createApiClients<APIRouter>({ routes: apiRoutes });
// src/app/map/page.native.tsx
import { useNavigate, useServerQuery } from "@farm.js/native";
import { useState } from "react";
import { FlatList, Pressable, Text } from "react-native";
import { apiClient } from "../../lib/api";

// The same typed call a web page makes: GET /api/stations, typed from the route's handler.
async function listStations() {
  const { data, error } = await apiClient.stations.get();
  if (error) throw error;
  return data;
}

export const metadata = { title: "Map" };

export default function MapScreen() {
  const navigate = useNavigate();
  const { data = [], refetch } = useServerQuery(listStations, undefined);
  // `pending` is true only for the first load, so pull-to-refresh tracks its own state.
  const [refreshing, setRefreshing] = useState(false);
  return (
    <FlatList
      data={data}
      refreshing={refreshing}
      onRefresh={() => {
        setRefreshing(true);
        refetch()
          .catch(() => {})
          .finally(() => setRefreshing(false));
      }}
      renderItem={({ item }) => (
        <Pressable onPress={() => navigate(`/stations/${item.id}`)}>
          <Text>{item.name}</Text>
        </Pressable>
      )}
    />
  );
}
@farm.js/nativeNotes
useNavigate()navigate(href, { replace }) by URL; the navigator picks the next screen, web or native
useRouteParams()Route params, such as { id: "7" } for /notes/[id], after the base path and locale prefix
useServerQuery(fn, input)Runs any async fn(input) on mount and when input changes: { data, error, pending, refetch }
serverFetch(path, init)fetch against mobile.server (the dev server under farm dev --mobile), with the base path and the device session once signed in
signOut()Low-level "forget this device": revoke its token, delete it, clear the WebView cookies
serverUrl(path)The absolute URL of a path on mobile.server
export const metadata.titleThe native bar's title

Native screens call the same API routes as the web client, over HTTP to mobile.server, with the same typed client: @farm.js/core/api/client has a React Native build (Metro picks it through the react-native export condition) whose createApiClients sends requests through serverFetch unless given a baseURL or fetch of its own. Import the client from @farm.js/core/api/client, not @farm.js/core/client, so one lib/api.ts serves both. Native screens send no cookies. Once the user has signed in, on a web screen or a native one, serverFetch sends the device's session token, so protected routes see the same user (see Auth on mobile).

Images a native screen imports ship with the app, as in a React Native app:

// src/app/map/page.native.tsx, next to blaze@2x.png and blaze@3x.png
<Image source={require("./blaze.png")} style={{ width: 24, height: 36 }} />

Metro picks the scales the device needs. Under farm dev --mobile it serves the files. In a farm build --mobile app they are in the app where React Native looks for them: Android resources (res/drawable-<density>/, named from the image's path) and, on iOS, assets/ next to the screens' bytecode. A build ships only the images the screens still import. Images by URL (source={{ uri }}) load over the network as usual. The Mobile Android and Mobile iOS workflows open examples/mobile's /map in the built app on an emulator and a simulator and check that its bundled image shows.

Server functions work from native screens as they do in server code: import one and call it.

// src/app/camera/page.android.tsx
import { createNote } from "../../features/notes/server"; // createServerFn

await createNote({ title: "Photo note" }); // runs on mobile.server, typed as on the web
  • The bundle never holds the server module. Metro resolves a module that defines server functions to a generated stub, .farm/output/mobile/native/server-fns/<hash>.js, whose exports post { id, input } through serverFetch to POST /api/__farm/native/fn. An id is a hash of the module's path from the app root and the export name, so the app and the server agree without sharing a build. A native bundle may import nothing else of @farm.js/core than @farm.js/core/api/client: a server module Farm does not recognize as one (a namespace import of createServerFn, a wrapper factory around it) imports @farm.js/core somewhere, and stops the bundle with a message rather than ship server code. Define the functions native screens import with createServerFn or createServerQuery imported by name.
  • The server runs only what native screens import. Farm follows each native screen's app imports (relative, @/, tsconfig paths) to the server function modules it reaches; a production build bundles exactly those, and an id outside them answers 404.
  • Same pipeline, same user. The endpoint is an ordinary API route, so middleware and runtime.before hooks run first (the mobile session plugin turns a signed-in device's token into its session), then the function's own input schema, middleware, handler, and output schema. Input and result travel as JSON. A declared error throws a FarmServerFnError with its code, status, and data; any other failure throws server_error and is logged on the server only.
  • Only the app calls it. The endpoint requires X-Farm-Mobile and refuses requests with fetch metadata (Sec-Fetch-*) or another origin's Origin. A browser sends fetch metadata on every request, and a cross-site page cannot add the header without a CORS preflight that carries its own Origin, so no page can call a function with a visitor's cookies. Input with __proto__, constructor, or prototype keys is refused, the API route's body size limit applies, and a result that is not JSON answers server_error.
  • Each imported function is a public endpoint. Anyone who sends the app's request shape can call a function a native screen imports, with any input, as with a server action on the web: check the input (its input schema) and the caller's rights (its middleware) inside it. Functions no native screen imports are not reachable through this endpoint.
  • Not yet: exports other than server functions from those modules (they are not in the stub), and a redirect() in a function (it throws a FarmServerFnError with redirect set; navigate yourself). A server module a screen starts importing during farm dev --mobile stops the native bundle with a message to restart, so server code never reaches the app. The endpoint lives under /api, so an app that moves its API base path elsewhere does not get it yet.

A screen navigates by URL only, so farm-routes.json always decides what opens next: a link from a native screen can open a web screen, a modal, a tab, or another native screen, and back returns to it.

  • Build. farm build --mobile writes one entry that registers every native screen under its module id, bundles it with Metro, and compiles it to Hermes bytecode with the hermesc that ships with the app's react-native. The bytecode is the only JS a release app runs; no code is downloaded.
  • Dev. farm dev --mobile starts Metro next to the dev server (port 8081, or the next free one) and the debug app loads the screens from it, with Fast Refresh.
  • Native modules. A community React Native module in the app's dependencies, such as @react-native-community/netinfo, is linked into the Android app: its Java or Kotlin package is registered with React Native, its codegen library is built into libappmodules.so, and a native screen imports it as in any React Native app. Farm reads only the app's own dependencies, the packages Metro bundles for the screens; devDependencies and dependencies of dependencies are never linked, and @react-native-community/cli is not needed. A module must support the new architecture (a codegenConfig in its package.json): one without it, such as react-native-device-info 15.0.2, fails the command with an error that names the package instead of being skipped. The app's own modules in src/native/ link the same way (App native modules). iOS links the same packages without CocoaPods (React Native screens on iOS). A debug APK with netinfo and an app module, and one with an app module alone, build and run on an Android 15 emulator.
  • Runtime. React Native's new architecture only: Hermes, Fabric, bridgeless. One runtime is shared by every native screen, each its own surface. It starts after the first screen shows content, so a web start screen pays nothing at launch.
  • Web twin. If the runtime fails to start, the bundle is missing or broken, or a screen throws while loading or rendering, the navigator shows the route's page.tsx in a WebView instead and logs the reason (adb logcat -s FarmShell). A screen whose module throws while loading logs <module id> failed to load: <error>, such as app/map failed to load: ReferenceError: stations is not defined. That is the build behavior. In dev, Metro reports a module that throws while loading as fatal, so React Native's error screen shows the error instead of hiding it behind the web page.
  • Cost. An app pays for React Native only when a route resolves to a native screen. Without one, the generated project has no React Native dependency, Kotlin, or asset. With one, the project adds react-android and hermes-android, compiles with Kotlin 2.2, bundles the screens' bytecode, and builds one small native library (libappmodules.so) that React Native needs to reach its core modules. In numbers:
    • The first Gradle build downloads about 400 MB of React Native AARs.
    • libappmodules.so (73 KB) needs the Android NDK 27.1.12297006 (2.4 GB) and CMake 3.22.1 (94 MB), which Gradle installs on the first build once the SDK licenses are accepted (sdkmanager --licenses).
    • The debug APK is about 73.7 MB, unstripped.
    • The example's screen bundle compiles to 1.23 MB of Hermes bytecode (Metro 18.5 s, hermesc 1.6 s).
    • Only the 64-bit ABIs are built (arm64-v8a, x86_64), so 32-bit devices are not supported.
    • An app that links at least one native module, a community one or its own, builds through React Native's Gradle plugin and its toolchain, loaded from the app's node_modules: Android Gradle plugin 9.2.1 and Gradle 9.4.1 instead of the shell's 8.7.3 and 8.11.1, and JDK 17 or newer. The plugin compiles itself with a JDK 17 toolchain, so on a machine without JDK 17 the first build also downloads one (about 490 MB in ~/.gradle/jdks), and Android build-tools 36.0.0. Apps without native modules keep the shell's toolchain and generate the same project as before.

Native files import react-native, which a web-only checkout may not have. Keep them out of the web type-check and check them on their own:

// tsconfig.json
{
  "compilerOptions": {
    /* ... */
  },
  "include": ["src"],
  "exclude": ["src/**/*.native.tsx", "src/**/*.android.tsx", "src/**/*.ios.tsx", "src/native"],
}
// tsconfig.native.json: tsc -p tsconfig.native.json, once react-native is installed
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "lib": ["ESNext"], "customConditions": ["react-native"] },
  "include": ["src/**/*.native.tsx", "src/**/*.android.tsx", "src/**/*.ios.tsx", "src/native"],
  "exclude": [],
}

The native-screen report

Auto-porting a page to a native screen is driven by a build-time static-analysis pass, the analyzer. It parses each page's source into an abstract syntax tree (AST) with Oxc, walks that tree, and classifies every JSX element, class name, inline style, browser API, and import against a portability model. From the parts it can map, it builds a platform-neutral UI tree (an intermediate representation, or IR) that a native emitter turns into a React Native, Compose, or SwiftUI screen.

The analyzer pipeline — parse each page, classify every node as a port (renders native) or a block (stays web), collect the ports into the IR, then conservatively pick the tier — is animated in the blog post Your web app, native.

With auto: "on", a native-tier route becomes a server-rendered native screen, and a port route on Android becomes a generated React Native port of its page.

farm config inspect --mobile --report   # table, plus .farm/output/mobile/native-report.json
route        screen  tier          decision  mapped  portable  reason
/feed        native  -             -                           src/app/feed/page.native.tsx
/notes       web     react-native  port      12/12   100%      portable; input, list
/notes/[id]  web     web           web       9/10    90%       src/app/notes/[id]/page.tsx:41:9 dangerouslySetInnerHTML
/settings    web     native        web       18/18   100%      renders natively, no JavaScript on the device

Tiers

Each analyzed route gets a tier, the first one nothing rules out:

TierWhat it meansRuled out by
nativeThe server renders the page into native views, with no JavaScript on the device. Async pages and server data are fine: they run on the server, as for the web pagehooks, event handlers, "use client" modules, package components, and the blockers below
react-nativeA React Native screen ported from the page can run its logic on the devicethe blockers below, async pages, server components, Node.js APIs, and packages not on the list
webA WebView of the page—

The native tier looks only at the page and its app modules: the native shell draws the chrome, so layouts are not part of a native screen. The report keeps the two lists apart, nativeBlockers for the native tier and blockers for a React Native port, and the table shows the first blocker of the tier to aim for.

Walking the AST, it visits each page, its app modules, and the layouts a layout.native.tsx does not replace, and sorts what it finds into two buckets:

  • Ports: div/section/… → View, p/span/headings → Text, img → Image, button/a → Pressable, text input/textarea → TextInput, ul/ol → List; @farm.js/ui and @farm.js/native; Link, navigation hooks, and server function hooks from @farm.js/core; the flex, spacing, size, color, border, radius, and type Tailwind subset, with dark:; inline styles with those properties; onClick, and input change handlers; server function modules, which the screen calls over the network.
  • Blocks, so the route stays web: canvas, iframe, video, table, svg, select, form; dangerouslySetInnerHTML, element refs, spread props, other DOM events; Sass and other non-CSS stylesheets, grid, fixed/sticky, transitions, responsive and hover: variants, arbitrary values, runtime class names; window, document, storage and observer APIs, useLayoutEffect, createElement; DOM packages and any package not on the list; server components, async pages, and Markdown pages.

The decision is deliberately conservative: a route is port only when it has zero blockers and at least one win (a rendered list, a controlled input, a timer). A portable route with no win is reported as such and stays web, and any doubt is web. Every blocker is reported with its file:line:column, a rule name (such as unmapped-element or unknown-package), and a message. With auto: "on", a native-tier route is recorded in farm-routes.json as a server screen with source: "analyzer", and a port route as a native screen whose file is its generated port. A port the emitter cannot write stays web with requested: "native" and a warning. Facts are cached by file content in .farm/cache/mobile/ and keyed by the analyzer and Farm version, so the report is cheap to re-run.

Generated React Native ports

For a port route, Farm writes a React Native screen from the page itself into .farm/output/mobile/ports/, and registers it like a hand-written page.native.tsx. The copy is the page as written, with each DOM element renamed to @farm.js/native/elements:

// .farm/output/mobile/ports/src/app/lab/packing/page.tsx (generated)
<__FarmDom.input value={draft} onChange={(event) => setDraft(event.target.value)} />
<__FarmDom.button onClick={() => setItems([...items, draft])}>Add</__FarmDom.button>
  • The page's logic runs unchanged. Hooks, state, and handlers are the page's own code; the elements adapt DOM props and events (onClick, onChange with event.target.value), wrap bare text in Text, and pass inherited text styles down as CSS does. Link navigates through the shell. App components the page imports are copied the same way; other modules are imported where they are.

  • Styles are the web page's, in the browser's cascade order: the browser's default styles (or Tailwind's preflight), Tailwind utilities, the app's own rules, then the inline style. A production build links all of an app's CSS on every page, so the emitter reads every plain .css file the app imports and resolves each element's styles at build time, light and dark:

    • @import "tailwindcss" (Tailwind 4) turns on preflight, and each static className utility resolves through the same table as the native tier, with the app's @theme on top: colors (oklch, hex, rgb), --spacing, text sizes and line heights, radii, weights, tracking, and initial resets. A value that reads a :root variable (@theme inline { --color-surface: var(--surface) }) takes its dark value from prefers-color-scheme: dark, so the class changes with the device theme;
    • the app's rules that match an element by its type and classes alone (h1, .card, button.primary, *, :root, body), with inherit (font: inherit) honored on form controls; body gives the screen its background, text style, and margin.

    A rule that also depends on where the element renders (main h1, .card p, li + li, attributes, :first-child) keeps the route web, unless it needs a class nothing around the page renders. With Tailwind loaded, a class neither the table nor the app's CSS knows keeps the route web too, since it may be a utility the table cannot draw.

  • Anything it cannot emit keeps the route web, with the reason in the warning: class names built at runtime, useContext, server functions, @farm.js/core APIs other than Link, packages outside the pure list, asset imports and CSS module names, dynamic import(), and stylesheets that change what classes mean (@utility, @custom-variant such as a class-based dark mode, @apply, @layer, other @imports, Tailwind 3's @tailwind) or are not plain CSS.

Not yet: ports in farm dev --mobile (the analyzer runs in builds, so they show as web screens in dev), @theme on server-rendered native screens (their classes use the default theme), iOS, FlatList for long lists, event bubbling from a child to a parent's onClick, and a live light/dark switch (a screen takes the theme it opened with). The browser's default styles are Chrome's, approximated.