Farm.js

Routing

Farm uses an app directory routing model with static routes, dynamic segments, catch-all routes, and typed navigation.

The examples on this page use the default React renderer. Solid uses .tsx/.jsx route files, Vue uses .vue, and Svelte uses .svelte. The route tree and server contracts stay the same; see Renderers for component conventions and feature compatibility.

File routes

FileURL
src/app/page.tsx/
src/app/about/page.tsx/about
src/app/about/page.mdx/about
src/app/blog/[slug]/page.tsx/blog/:slug
src/app/docs/[...slug]/page.tsx/docs/:slug*

With Vue, the same routes use names such as src/app/page.vue and src/app/blog/[slug]/page.vue.

With Svelte, use src/app/page.svelte and src/app/blog/[slug]/page.svelte.

Programmatic routes

File routes cover most apps. Define routes in code instead when a URL can't be a folder, or when a page and the endpoints it needs belong together. Export defineRoutes from src/routes.ts (or routes.tsx, farm.routes.ts, farm.routes.tsx, and their .js/.jsx forms):

src/routes.tsx
import { createEndpoint, defineRoutes } from "@farm.js/core";
import { z } from "zod";
import { ChangelogPage } from "./features/changelog/page";
import { listReleases, renderReleasesFeed } from "./features/changelog/releases";

export default defineRoutes(({ page, api }) => [
  page("/changelog", { component: ChangelogPage }),
  api("/changelog/feed.xml", {
    GET: async () =>
      new Response(renderReleasesFeed(await listReleases()), {
        headers: { "content-type": "application/rss+xml" },
      }),
  }),
  api("/changelog/releases.json", {
    GET: createEndpoint(
      {
        method: "GET",
        query: z.object({ limit: z.coerce.number().int().positive().default(20) }),
      },
      async ({ query }) => Response.json(await listReleases(query.limit)),
    ),
  }),
]);
  • api() takes the same handlers a route.ts exports, one per method (GET, HEAD, QUERY, POST, PUT, DELETE, PATCH, OPTIONS): a plain (request) => Response or a createEndpoint(...) with request validation.
  • Paths don't need the /api prefix that file API routes live under, and they can end in a file extension such as /feed.xml or /data.json.
  • page() and layout() take a component plus the route options covered below: data, guards, search params, pending, error and not-found UI, and route runtime settings.
  • Programmatic pages join the same route table as file routes, so generated route types, PageProps and typed links include them. Programmatic API routes are not in the generated API client types yet; use a file route under src/app/api when you need a typed client.

Prefer a file route when the URL maps to a folder. Reach for defineRoutes for colocated features, URLs a folder can't express, and route lists generated from data or configuration.

Named slots and intercepted routes

An @name directory gives its owning layout another rendered node alongside children. Use slots for independently composed areas such as activity panels, drawers, and modals. The slot name and interception marker never become part of the public URL.

src/app/feed/
├── layout.tsx
├── page.tsx
├── photo/[id]/page.tsx
└── @modal/
    ├── default.tsx
    └── (.)photo/[id]/page.tsx

The layout receives the @modal result as modal:

import type { ReactNode } from "react";

export default function FeedLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal?: ReactNode;
}) {
  return (
    <main>
      {children}
      {modal}
    </main>
  );
}

default.tsx renders when no page in that slot matches. A client-side navigation from /feed to /feed/photo/42 selects @modal/(.)photo/[id]/page.tsx, places it in the existing modal slot, and keeps the surrounding feed state alive. A direct request or refresh at /feed/photo/42 renders the canonical photo/[id]/page.tsx instead.

Interception markers are relative to the slot's owning route:

MarkerTarget base
(.)Same route level
(..)One route level above
(..)(..)Two route levels above
(...)App root

Make an intercepting slot a Client Component when it needs event handlers such as closing a modal with router.back(). Farm falls back to canonical document navigation if it cannot safely mount the intercepted slot in the current page.

Dynamic params

src/app/users/[id]/page.tsx
import type { PageProps } from "@farm.js/core";

export default function UserPage({ params }: PageProps<"/users/[id]">) {
  return <div>User: {params.id}</div>;
}

The route literal is checked against the generated application routes and narrows params to { id: string }. The same route-aware generic is available on LayoutProps, LoadingProps, ErrorProps, MetadataProps, and LayoutMetadataProps. Omitting the generic keeps the existing Record<string, string> behavior for gradual adoption.

Use GenerateStaticParams to check build-time paths against the same route:

import type { GenerateStaticParams } from "@farm.js/core";

export const generateStaticParams: GenerateStaticParams<"/users/[id]"> = async () => [
  { id: "ada" },
  { id: "grace" },
];

For an optional catch-all such as /docs/[[...slug]], omit slug or return slug: [] to generate the parent /docs page. Farm exposes that empty capture as params.slug === "", the same value produced by request-time routing. JavaScript callers that return { slug: undefined } or { slug: null } are normalized to the same empty value; typed callers should prefer omission or an empty array.

Typed navigation

Farm writes the route union into the consolidated src/farm.d.ts declaration file. Link hrefs and route component props accept real routes without widening everything to plain string. Link hrefs can also include query strings and hash fragments. Changing only the fragment preserves SPA state, honors push versus replace history, and does not request route data again. Relative navigation, prefetch, and shallow history URLs resolve against the current document in development and production. For example, ?tab=settings from /users/123?tab=profile keeps /users/123, and 456 from /users/123 resolves to /users/456. Native anchor behavior still takes precedence: for example, a Link with a download attribute is handled by the browser instead of Farm's SPA router. Absolute URI schemes such as mailto:, tel:, sms:, and same-origin blob: URLs are passed through unchanged and are never prefetched as app routes. Literal custom schemes such as customapp:open are validated from their URI grammar and work without registration. Viewport prefetch uses a short scroll guard and is cancelled if its link unmounts before the guard expires. Intent prefetches are deduplicated while active; after an attempt settles, a later hover, focus, or touch can retry while successful route data remains deduplicated by the router cache. The development page-data router reuses a successfully decoded JSON prefetch response when navigation overlaps it with the same URL, interception origin, and active layout chain. Refreshes and different contexts fetch independently, and failed prefetches are retried by navigation. Cancelling a waiting navigation does not cancel its background prefetch. Deferred streaming responses keep independent requests and cancellation ownership; a waiting navigation starts its own request once the prefetch response headers identify a deferred stream. Production HTML navigation also reuses matching in-flight prefetches. Different interception contexts and deployment identities stay separate; failed prefetches are retried by navigation. Refresh bypasses pending prefetches, and clearing the cache prevents their late responses from repopulating it. Cancelling navigation does not abort a shared background prefetch. When a production HTML request redirects, Farm hands the original URL to a full document navigation so the browser preserves the redirect destination, fragments, and push/replace history. Prefetching a redirect never navigates the page or caches destination HTML under the original URL. A same-origin link whose response is not HTML, such as an RSS feed, JSON, or a file served by an API route, is handed to the browser the same way, so it opens as its own document. Prefetching such a link never caches it as page HTML. Internal Link hrefs stay app-relative: when basePath: "/console" is configured, href="/about" renders and navigates to /console/about. Do not add the base path to route hrefs yourself. For a reusable custom-scheme type, use ExternalHref<`customapp:${string}`> (or declaration-merge LinkExternalUriSchemes when the scheme should belong to the default ExternalHref union).

Client navigation
import { Link } from "@farm.js/core/client";

export function Nav() {
  return (
    <>
      <Link href="/about">About</Link>
      <Link href="/blog/farm-routing?from=docs">Routing</Link>
    </>
  );
}

Same-page fragment links (such as #settings) and Back/Forward between those fragments keep the current page mounted without loading route data. Native anchor scrolling and hashchange listeners still work. A different pathname, query string, or intercepted-route context still performs route navigation.

Lightweight router helpers

Use the lightweight router when client components, layouts, breadcrumbs, tabs, or tests need to match app routes without adding a separate routing library.

src/lib/router.ts
import { createFarmRouter } from "@farm.js/core/router";

export const router = createFarmRouter(["/", "/dashboard", "/users/[id]", "/docs/[[...slug]]"]);
const match = router.match("/users/ada?tab=settings");

if (match) {
  console.log(match.route.path); // /users/[id]
  console.log(match.params.id); // ada
}

Build hrefs from the same route patterns:

const href = router.build("/docs/[[...slug]]", {
  slug: ["core", "routing"],
});

This returns /docs/core/routing. Optional catch-all params can be omitted, static routes win over dynamic routes, and route groups such as (marketing) do not appear in the URL.

Programmatic route patterns must remain stable when a browser parses their URLs. Farm rejects dot-only segments, encoded path separators or control characters, and raw backslashes instead of building a URL that the browser would canonicalize to a different route. Ordinary encoded static segments such as /files/hello%20farm remain supported.

These matching rules also apply with experimental React Server Components enabled. Farm prepares page precedence when the route entry initializes, so filesystem discovery order cannot make /users/[id] hide /users/new. Optional catch-all pages match both their parent URL and deeper paths.

Route matching decodes each URL path segment once before comparing static names or exposing params. Route names remain literal (including %), and malformed percent escapes remain literal instead of aborting the request.

A required or optional catch-all must be the final URL segment. Farm reports paths such as docs/[...slug]/edit/page.tsx during route discovery because the catch-all would otherwise consume the edit segment and make the route unreachable.

Each dynamic segment in one route must have a unique parameter name. Farm rejects paths such as teams/[id]/members/[id]/page.tsx instead of silently replacing the outer id value. The prototype-sensitive names __proto__, constructor, and prototype are reserved because JavaScript cannot represent them safely in the plain params object. For navigation state, router.isActive(pattern, pathname, { exact: false }) also matches descendants after dynamic segments, such as /users/42/settings for /users/[id].

Supplied path parameters must contain non-empty segments; omit an optional catch-all instead of passing an empty string.

Client components can pass the same route list to useRouter when they want current route params:

import { useRouter } from "@farm.js/core/client";

export function CurrentUserTab() {
  const router = useRouter({
    routes: ["/users/[id]", "/users/[id]/settings"],
  });

  return <span>{router.params.id}</span>;
}

Imperative push() and replace() calls use SPA navigation for same-origin routes. Absolute cross-origin URLs use normal document navigation instead of Farm's local page-data endpoint. Internal router paths use the configured application basePath, matching Link; already-prefixed paths are preserved.

Use useBlocker when a client component needs to protect unsaved work before SPA navigation continues.

"use client";

import { useBlocker } from "@farm.js/core/client";

export function ProductForm({ isDirty }: { isDirty: boolean }) {
  useBlocker({
    when: isDirty,
    message: "You have unsaved changes.",
  });

  return <form>{/* ... */}</form>;
}

Blockers apply to Farm SPA navigation and browser unload prompts. They improve UX, but they do not replace server-side validation or persistence checks.

Page state

Use page state for shallow UI state that belongs in browser history but should not reload route data: modals, drawers, selected panels, or temporary filters.

"use client";

import { usePageState, useRouter } from "@farm.js/core/client";

export function ProductToolbar() {
  const router = useRouter();
  const page = usePageState<{ modal?: "cart"; drawer?: "filters" }>();

  return (
    <>
      <button onClick={() => router.pushState({ modal: "cart" })}>Cart</button>
      <button onClick={() => router.replaceState({ drawer: "filters" })}>Filters</button>
      {page?.modal === "cart" ? <CartModal /> : null}
    </>
  );
}

Page state is stored in history.state, so back/forward navigation restores the previous state without changing the URL unless you pass an href.

Scroll restoration

Farm restores window scroll during SPA navigation. Register nested scroll areas when a layout owns its own scroll container. Locations with different query strings keep independent positions, so browser history restores the correct filtered, searched, or paginated view.

"use client";

import { useScrollRestoration } from "@farm.js/core/client";

export function DocsSidebar() {
  const ref = useScrollRestoration<HTMLDivElement>("docs-sidebar");
  return <div ref={ref}>{/* links */}</div>;
}

Use stable keys per scroll container. If two elements share a key, the latest mounted element owns that stored position.

Route data cache

Programmatic routes can cache the value returned from data.main. This is useful for product pages, docs pages, dashboards, and other route data that should be reused during server rendering or prefetching. The first createRoute argument is a pathname pattern only; declare typed search parameters with search and add query strings or hashes when building a navigation URL.

import { createRoute, invalidate } from "@farm.js/core";
import { z } from "zod";
import { ProductPage } from "./page";

export const ProductRoute = createRoute("/products/[id]", {
  params: z.object({ id: z.string() }),

  data: {
    key: ({ params }) => ["product", params.id],
    staleTime: "30s",

    async main({ params }) {
      return {
        product: await db.product.findUnique({ where: { id: params.id } }),
      };
    },
  },

  component: ProductPage,
});

export async function saveProduct(id: string, name: string) {
  await db.product.update({ where: { id }, data: { name } });
  await invalidate(["product", id]);
}

key enables caching. When a cached entry is still fresh, Farm reuses the previous data.main result. before still runs for each request, and after still runs with the returned data, so setup and logging hooks keep their normal behavior.

staleTime accepts a number of milliseconds or a duration string such as "500ms", "30s", "5m", or "1h". Omit staleTime when data should stay cached until invalidated.

Farm also tags route data by the rendered path, so revalidatePath("/products/123") invalidates the matching route data entry. Use tags or paths when one mutation should refresh more than one route:

export const ProductRoute = createRoute("/products/[id]", {
  data: {
    key: ({ params }) => ["product", params.id],
    tags: ({ params }) => [`product:${params.id}`, "products"],
    paths: ({ params }) => [`/products/${params.id}`, "/products"],
    async main({ params }) {
      return { product: await getProduct(params.id) };
    },
  },
  component: ProductPage,
});

Cache keys are part of your data security model. If data depends on the current user, role, tenant, locale, or draft mode, include that value in key or avoid caching that route. Route cache invalidation improves freshness, but API routes and server functions still need their own authorization checks.

Route actions

An action is a typed server function, not necessarily a URL route. Use a standalone createServerFn when an operation is shared by several pages, belongs to a background job or integration, or does not have one resource owner:

import { createServerFn } from "@farm.js/core/server-fn";
import { z } from "zod";

export const sendInvite = createServerFn({
  input: z.object({ email: z.string().email() }),
  async handler({ input }) {
    return { queued: true, email: input.email };
  },
});
import { useAction } from "@farm.js/core/client";
import { sendInvite as sendInviteFn } from "./actions";

const sendInvite = useAction(sendInviteFn);
await sendInvite({ email: "ada@example.com" });

Attach actions to a route when they are commands on that route's resource. This gives the page a typed namespace for related mutations, lets useAction(route) select a declared default, and lets Farm expose an action-only client proxy without shipping the route loader, component, or database code to the browser. The action still uses Farm's server-function transport; it does not become a public URL such as /products/update.

With React Server Components and Server Actions enabled, a programmatic route can own named server functions. Keep the functions in a dedicated server module so Farm can replace the route with an action-only proxy when a Client Component imports it. The proxy contains no loader, component, or database code.

src/features/products/actions.ts
import { createServerFn } from "@farm.js/core/server-fn";
import { z } from "zod";
import { db } from "./db";

export const updateProduct = createServerFn({
  input: z.object({
    id: z.string(),
    name: z.string().min(2),
  }),
  invalidates: ({ input }) => [{ key: ["product", input.id] }],
  async handler({ input }) {
    return db.product.update({
      where: { id: input.id },
      data: { name: input.name },
    });
  },
});

export const publishProduct = createServerFn({
  input: z.object({ id: z.string() }),
  async handler({ input }) {
    return db.product.update({
      where: { id: input.id },
      data: { published: true },
    });
  },
});

Attach the imported functions to the route. defaultAction selects the function returned by route.action and used by useAction(route). When the route has one action, or no explicit default, Farm selects the first declared action.

src/features/products/product.route.tsx
import { createRoute } from "@farm.js/core/routes";
import { ProductPage } from "./product-page";
import { publishProduct, updateProduct } from "./actions";
import { db } from "./db";

export const ProductRoute = createRoute("/products/[id]", {
  data: {
    key: ({ params }) => ["product", params.id],
    async main({ params }) {
      return { product: await db.product.findUniqueOrThrow({ where: { id: params.id } }) };
    },
  },
  actions: {
    update: updateProduct,
    publish: publishProduct,
  },
  defaultAction: "update",
  component: ProductPage,
});

Server code calls either the default or a named action as a normal typed function. Direct calls run in process, while input/output validation, middleware, declared errors, and invalidation keep their server-function behavior.

await ProductRoute.action({ id: "p1", name: "Keyboard" });
await ProductRoute.actions.publish({ id: "p1" });

In a Client Component, useAction(ProductRoute) wraps the default function in a callable RPC and adds React state. Call the wrapper itself; no .submit() method is required.

"use client";

import { useAction } from "@farm.js/core/client";
import { ProductRoute } from "./product.route";

export function RenameProduct({ id }: { id: string }) {
  const update = useAction(ProductRoute);

  return (
    <button disabled={update.pending} onClick={() => update({ id, name: "Keyboard" })}>
      {update.pending ? "Saving…" : "Rename"}
    </button>
  );
}

The wrapper exposes pending, status, data, error, reset, formAction, and Form. It keeps the action's input, result, and declared error types. Choose another named action explicitly when needed:

const publish = useAction(ProductRoute.actions.publish);
const result = await publish({ id: "p1" });

For both useAction and useServerFn, pending means at least one submission since the last reset is still running. status, result / data, and error describe the latest submission. If that submission finishes before an older one, its status becomes success or error while pending remains true. Finishing older work only reduces the pending count; it cannot replace the latest result, error, or status. A new submission sets status back to pending, and reset() returns it to idle.

The same wrapper supports progressive forms. The form performs a native server action before hydration and uses the tracked RPC lifecycle after hydration:

const update = useAction(ProductRoute);

return (
  <update.Form>
    <input type="hidden" name="id" value={product.id} />
    <input name="name" defaultValue={product.name} />
    <button disabled={update.pending}>Save</button>
  </update.Form>
);

Route action entries must be imported identifiers such as { update } or { update: updateProduct }. Do not create them inline inside createRoute; the separate module is the server boundary Farm uses to generate safe browser references. When provided, defaultAction must be a string literal matching one of those entries.

useAction keeps Form's component identity stable across renders, including when you pass inline optimistic callbacks or change the target action. Existing fields retain their DOM identity, unsaved input, focus, and selection. Submissions use the latest action and options; React's normal form reset after a successful function action still applies.

Deferred route data

Use defer() for secondary data that should not delay the route shell. Farm returns the value from data.main as soon as its directly awaited work finishes, then streams explicitly deferred fields into nested React Suspense boundaries.

src/features/products/page.tsx
import { Suspense, use } from "react";
import type { Deferred } from "@farm.js/core";

export function ProductPage({ data }: ProductPageProps) {
  return (
    <main>
      <h1>{data.product.name}</h1>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews reviews={data.reviews} />
      </Suspense>
    </main>
  );
}

function Reviews({ reviews }: { reviews: Deferred<Review[]> }) {
  const resolvedReviews = use(reviews);
  return resolvedReviews.map((review) => <ReviewRow key={review.id} review={review} />);
}
src/farm.routes.tsx
import { createRoute, defer } from "@farm.js/core";
import { ProductPage } from "./features/products/page";

export const ProductRoute = createRoute("/products/[id]", {
  data: {
    async main({ params }) {
      const reviews = defer(getProductReviews(params.id));
      const product = await getProduct(params.id);

      return {
        product,
        reviews,
      };
    },
  },
  component: ProductPage,
});

In this example, both requests start together, but only getProduct controls when the route shell is ready. getProductReviews does not block the shell. The component receives reviews as Deferred<Review[]>, so React use() resolves it with full type inference.

data.after runs after main returns and receives the deferred promise without waiting for it. This keeps logging and request cleanup hooks from extending the stream; await a deferred field inside after only when that delay is intentional.

Farm uses a streaming page-data response for SPA navigation and serializes settled deferred values for hydration. Rejections expose a generic DeferredDataError to the browser while the original error remains in server logs. Deferred values and their resolved results must be JSON-serializable when they cross into a hydrated component.

Use defer() for independent secondary sections such as reviews, recommendations, activity, or analytics. Keep data required for the title, authorization decision, redirect, or primary above-the-fold content directly awaited in main. defer() is explicit: ordinary nested promises are not automatically treated as streamed route data.

Typed Search Params

Programmatic routes can validate URL search params and define cleanup rules in one place. Use search.schema for typed route input, stripDefaults for clean URLs, preserve for params that should carry across links, and temporary for one-time UI params.

import { createRoute } from "@farm.js/core";
import { z } from "zod";

export const ProductRoute = createRoute("/products/[id]", {
  search: {
    schema: z.object({
      tab: z.enum(["info", "reviews"]).default("info"),
      locale: z.string().default("en"),
      toast: z.string().optional(),
    }),
    stripDefaults: true,
    preserve: ["locale"],
    temporary: ["toast"],
  },

  data: {
    async main({ params, search }) {
      return {
        product: await getProduct(params.id),
        tab: search.tab,
      };
    },
  },

  component: ProductPage,
});

With that route, /products/123?tab=info&locale=am&toast=saved gives the component typed search data:

{
  tab: "info",
  locale: "am",
  toast: "saved",
}

After the route has consumed it, Farm can clean the URL to /products/123?locale=am. tab=info is removed because it matches the schema default, and toast=saved is removed because it is temporary.

preserve is used by Farm links. If the current page is /products?locale=am, then a link to /products/[id] carries locale=am unless the link already provides its own locale.

Use this for tab state, pagination defaults, locale/tenant preservation, preview mode, and one-time params such as toast=saved. Keep security-sensitive values out of search params; they are user-editable URL state, not trusted server state.

Route context

Use context in farm.config.ts for request-scoped dependencies that guards and route data need: sessions, tenants, feature flags, or database clients. The value is available to programmatic route guard, data.before, data.main, cache key functions, and data.after.

import { defineConfig } from "@farm.js/core";
import { db } from "./src/db";
import { getSession } from "./src/session";

export default defineConfig({
  context: async ({ request }) => ({
    session: await getSession(request),
    db,
  }),
});

Add a module augmentation when you want autocomplete in route files:

import type { db } from "./src/db";
import type { getSession } from "./src/session";

declare module "@farm.js/core" {
  interface FarmAppContext {
    session: Awaited<ReturnType<typeof getSession>>;
    db: typeof db;
  }
}

Route context is server-only. Farm passes it to guards and data hooks without serializing it into browser props, so keep raw database clients and secrets in context and return only safe page data from data.main.

Route guards

Use guard when a route should be allowed or blocked before route data loads. Guards run after params/search validation and before data.before or data.main.

import { createRoute, redirect } from "@farm.js/core";

export const DashboardRoute = createRoute("/dashboard", {
  guard: async ({ context }) => {
    if (!context.session.user) {
      redirect("/login");
    }
  },

  data: {
    async main({ context }) {
      return { stats: await getDashboardStats(context.db) };
    },
  },

  component: DashboardPage,
});

Use guard for route flow: auth redirects, role gates, tenant checks, and early notFound() decisions. Use data.before when you want to prepare values that data.main needs. A guard is not a complete authorization boundary; repeat sensitive authorization inside API routes and server functions because those can be requested directly.

Route UI states

Programmatic routes can define local pending, error, and notFound components. pending is used as the Suspense fallback while route data resolves. error handles guard/data errors. notFound handles notFound() thrown from guard or data hooks.

import { notFound } from "@farm.js/core";

export const ProductRoute = createRoute("/products/[id]", {
  data: {
    async main({ params }) {
      const product = await getProduct(params.id);
      if (!product) notFound();
      return { product };
    },
  },

  pending: ProductSkeleton,
  error: ProductError,
  notFound: ProductNotFound,
  component: ProductPage,
});

Redirects are not rendered through error; they escape so Farm can return a real redirect response.

Nested segments

Folders become URL segments. Use normal folders for visible path segments and dynamic folders when the value comes from the URL.

Route tree
src/app/
  page.tsx
  dashboard/
    page.tsx
    settings/
      page.tsx
  blog/
    [slug]/
      page.tsx
  docs/
    [...slug]/
      page.tsx

This creates /, /dashboard, /dashboard/settings, /blog/:slug, and /docs/:slug*.

Markdown pages

Use page.md or page.mdx for static content routes. They behave like app pages, participate in layouts, and get route types.

Markdown/MDX visual routes currently use the React content renderer. Preact treats this as a preact/compat surface, while Solid, Vue, and Svelte applications can still serve renderer-neutral API content or ordinary static assets but should use renderer-owned component pages for visual routes.

src/app/about/page.mdx
# About

This page renders at `/about` and exposes source at `/about.md`.

When page.tsx and page.md or page.mdx share a folder, the React file owns the HTML route and the markdown file becomes its exact .md representation. Without a sidecar, Farm derives markdown from the rendered React page automatically.

Metadata And OG Images

Export metadata for static head tags or generateMetadata when the values depend on route params, search params, middleware data, or route data. Farm merges layout metadata from root to leaf, then applies the page metadata last.

During HTML-based client navigation, Farm reconciles the destination document's title, meta tags, canonical and alternate links, icons, manifest link, and page JSON-LD. Tags omitted by the destination are removed, so metadata from the previous route cannot remain active.

A layout can define a default title and a %s template for child segments. The layout itself uses the default; a child string title is substituted into the nearest parent template:

export const metadata = {
  title: {
    default: "Acme",
    template: "%s | Acme",
  },
};
src/app/products/[id]/page.tsx
import type { MetadataProps } from "@farm.js/core";

export const metadata = {
  description: "Product details",
  openGraph: {
    siteName: "Acme",
    type: "website",
  },
};

export async function generateMetadata({ params }: MetadataProps<"/products/[id]">) {
  const product = await getProduct(params.id);

  return {
    title: product.name,
    openGraph: {
      title: product.name,
      description: product.summary,
    },
    twitter: {
      card: "summary_large_image",
      title: product.name,
    },
  };
}

export default function ProductPage() {
  return <main>Product</main>;
}

Use generateMetadata in a layout when the same dynamic metadata flow should apply to every page in a route subtree. Farm passes the matched route params to each layout and merges the result before applying the page metadata. A catch-all docs layout can therefore load the current document for every nested docs URL:

src/app/docs/[...slug]/layout.tsx
import type { LayoutProps } from "@farm.js/core";

export async function generateMetadata({ params }: Pick<LayoutProps, "params">) {
  const slug = params.slug?.split("/") ?? [];
  const document = await getDocument(slug);

  return {
    title: document.title,
    description: document.description,
    openGraph: {
      title: document.title,
      description: document.description,
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: document.title,
      description: document.description,
    },
  };
}

export default function DocsLayout({ children }: LayoutProps) {
  return children;
}

Pair this layout with opengraph-image.tsx in the same [...slug] segment to generate a different PNG for each document. generateMetadata supplies the title, description, and social fields; the image file renders the PNG described below. Leave openGraph.images and twitter.images unset when Farm should attach the nearest generated image automatically. An explicit image value still takes precedence.

Article fields

When openGraph.type is "article", Farm also renders the Open Graph article properties:

import type { MetadataProps } from "@farm.js/core";

export async function generateMetadata({ params }: MetadataProps<"/blog/[slug]">) {
  const post = await getPost(params.slug);

  return {
    openGraph: {
      type: "article",
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: ["https://acme.test/team/ada"],
      section: "Engineering",
      tags: post.tags,
    },
  };
}

publishedTime and modifiedTime take an ISO string or a Date and render article:published_time and article:modified_time. authors and tags take a string or an array and render one article:author or article:tag per item. section renders article:section. Other openGraph.type values ignore these fields.

Favicons

Place favicon files in public/, then declare them through the root layout metadata. Files in public/ are served from the application root, so public/favicon.svg is available at /favicon.svg.

src/app/layout.tsx
import type { Metadata } from "@farm.js/core";

export const metadata: Metadata = {
  icons: "/favicon.svg",
};

Use the object form when you need multiple browser icons or an Apple touch icon:

import type { Metadata } from "@farm.js/core";

export const metadata: Metadata = {
  icons: {
    icon: [{ url: "/favicon.svg", type: "image/svg+xml", sizes: "any" }],
    shortcut: "/favicon.ico",
    apple: [{ url: "/apple-touch-icon.png", type: "image/png", sizes: "180x180" }],
  },
};

Root layout metadata applies the favicon to every route. Nested layouts and pages can override individual icon entries through their own metadata. Do not render a <link rel="icon"> element from the layout component; declaring metadata.icons lets Farm place the tags in the document head in both development and production.

Site verification

Search consoles confirm site ownership with a meta tag. Set the tokens once in the root layout:

src/app/layout.tsx
import type { Metadata } from "@farm.js/core";

export const metadata: Metadata = {
  verification: {
    google: "google-token",
    bing: "bing-token",
    yandex: "yandex-token",
    other: { "facebook-domain-verification": "facebook-token" },
  },
};

google, bing, and yandex render google-site-verification, msvalidate.01, and yandex-verification. other renders one <meta name content> per key. Every field accepts an array when a service needs more than one token. Like openGraph, verification merges one level deep, so a nested layout or page adds tokens and replaces the ones it repeats.

Application metadata routes

Use server-only metadata files when crawlers or browsers need an application-level document rather than an HTML <meta> tag. Farm discovers five conventions in src/app and route segments:

FilePublic routeDefault return type
sitemap.ts/sitemap.xmlMetadataRoute.Sitemap
robots.ts/robots.txtMetadataRoute.Robots
manifest.ts/manifest.webmanifestMetadataRoute.Manifest
llms.ts/llms.txtMetadataRoute.LlmsTxt
llms-full.ts/llms-full.txtMetadataRoute.LlmsTxt

The default export can be a literal value or a sync or async function. Functions receive the matched params, the current Request, its URLSearchParams, and the concrete route-segment path.

src/app/sitemap.ts
import type { MetadataRoute } from "@farm.js/core";

export const revalidate = 3600;

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const products = await listProducts();

  return [
    {
      url: "https://acme.test",
      lastModified: new Date(),
      changeFrequency: "daily",
      priority: 1,
    },
    ...products.map((product) => ({
      url: `https://acme.test/products/${product.id}`,
      lastModified: product.updatedAt,
      priority: 0.8,
    })),
  ];
}

Farm escapes XML values and supports language alternates through alternates.languages.

src/app/robots.ts
import type { MetadataRoute } from "@farm.js/core";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/admin/", "/preview/"],
    },
    sitemap: "https://acme.test/sitemap.xml",
    host: "https://acme.test",
  };
}

robots.txt controls crawling. Per-page indexing directives belong in metadata.robots, which renders a <meta name="robots"> tag. Besides index and follow, it accepts noarchive, nosnippet, noimageindex, max-snippet, max-image-preview, max-video-preview, and unavailable_after. googleBot takes the same directives, or a string, for a separate <meta name="googlebot"> tag:

import type { Metadata } from "@farm.js/core";

export const metadata: Metadata = {
  robots: {
    index: true,
    follow: true,
    "max-snippet": -1,
    "max-image-preview": "large",
    googleBot: { noimageindex: true },
  },
};

A root robots.ts replaces the robots.txt that agent.crawlers generates, and a public/robots.txt wins over both. All three take /robots.txt from the docs engine.

src/app/manifest.ts
import type { MetadataRoute } from "@farm.js/core";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "Acme Store",
    short_name: "Acme",
    description: "The Acme product catalog",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#16a34a",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
    ],
  };
}
src/app/llms.ts
import type { MetadataRoute } from "@farm.js/core";

export default function llms({
  pages,
  defaults,
}: MetadataRoute.LlmsTxtContext): MetadataRoute.LlmsTxt {
  return {
    ...defaults,
    sections: [
      { title: "Docs", links: pages.filter((page) => page.path.startsWith("/docs")) },
      { title: "Optional", links: [{ title: "Changelog", url: "https://acme.test/changelog" }] },
    ],
  };
}

llms.ts renders the llmstxt.org format: title as the H1, summary as a quote, details as Markdown, and each section as an H2 list of links. To write the whole file yourself, return it as a string instead; Farm serves it as the complete file and adds a final newline when it is missing. Besides the usual arguments, its function receives pages, the app's static pages, each linking to its Markdown mirror when md exposes one and to the page itself otherwise, and defaults, the index agent.llmsTxt would serve, so it can extend the generated file instead of starting over. An llms.ts replaces agent.llmsTxt at its path.

llms-full.ts serves /llms-full.txt the same way. When it returns the structured format, Farm inlines the Markdown mirror of every same-origin link that has one; a string is served as the complete file, with a final newline added when missing. Its function also receives markdown(url), which reads a page's mirror without the request's cookies and returns null when the page is unavailable.

// src/app/llms-full.ts: reuse the llms.txt structure and let Farm inline each page
export { default } from "./llms";

Farm automatically adds the nearest discovered manifest to rendered page heads unless metadata.manifest already supplies an explicit URL. A nested file keeps its route prefix: src/app/docs/sitemap.ts is served at /docs/sitemap.xml, and a file under [tenant] receives the concrete tenant param.

With the docs engine enabled, a root sitemap.ts, robots.ts, llms.ts, or llms-full.ts takes its path from the docs engine, which keeps serving the ones the app does not define. An app sitemap replaces the docs engine's instead of extending it, so list the docs pages in it when crawlers should find them there.

Generated metadata routes accept GET and HEAD and return 405 for other methods. They revalidate by default. Export revalidate = 300 for shared CDN caching or revalidate = false only for permanently immutable output. A returned Response is an escape hatch for custom XML, headers, or status codes.

feed.ts is not reserved yet because feeds need an explicit RSS, Atom, or JSON Feed contract. Use an API or programmatic route for feeds until that format is defined.

Alternate representations

Advertise other formats of a page, such as that feed route, with alternates.types. Each MIME type renders <link rel="alternate" type href>, with an optional title:

import type { Metadata } from "@farm.js/core";

export const metadata: Metadata = {
  alternates: {
    types: {
      "application/rss+xml": [{ url: "/blog/feed.xml", title: "Acme blog" }],
      "application/atom+xml": "/blog/atom.xml",
    },
  },
};

Relative URLs resolve against metadataBase, the same as alternates.languages. A page that sets alternates.types replaces its layout's whole types object.

When a page has a Markdown mirror, Farm adds <link rel="alternate" type="text/markdown" href="/blog/hello.md"> to its head next to the Link response header it already sends. Set alternates.types["text/markdown"] to advertise a different URL. Restricting md.expose or setting md: false removes the link along with the mirror.

Static metadata images

Place opengraph-image.png next to a page or layout segment for a zero-code, route-local social image. Farm supports .png, .jpg, .jpeg, .gif, and .webp files.

src/app/
  opengraph-image.png
  products/
    opengraph-image.png
    [id]/
      page.tsx

The root image is the application fallback. /products/opengraph-image.png applies to product pages and their descendants, while unrelated routes continue using the root image. Farm automatically reads the image dimensions and content type.

Add accessible preview text with an optional sidecar file:

src/app/products/opengraph-image.alt.txt
Acme product catalog preview

Farm serves the file through the extensionless /products/opengraph-image endpoint and adds a content fingerprint to the URL emitted in page metadata. Fingerprinted requests receive immutable caching, while direct unversioned requests revalidate with an ETag.

Use twitter-image.png when X/Twitter needs a different image. Otherwise, social platforms can use the Open Graph image. An explicit metadata.openGraph.images or metadata.twitter.images value always wins over a matching file.

Static images inside a dynamic segment are shared by every concrete route. Use a generated image when the preview must depend on route params.

Generated metadata images

Generated JSX metadata images currently use the React image renderer. Every renderer can use static opengraph-image.png, twitter-image.png, and explicit metadata image URLs.

Place opengraph-image.tsx or twitter-image.tsx next to a route segment when the image needs data or route params. Return ordinary stateless JSX: Farm owns the image endpoint and PNG renderer, so the component does not import ImageResponse or declare an API URL. The renderer is @vercel/og, which the app installs with pnpm add @vercel/og; farm build fails with that command when a generated image exists and the package is missing. Farm also adds the nearest matching image to the page head when openGraph.images or twitter.images is not already set.

src/app/products/[id]/opengraph-image.tsx
import type { PageProps } from "@farm.js/core";

export const size = { width: 1200, height: 630 };
export const alt = "Product preview";
export const revalidate = 300;

function ProductCard({ name, id }: { name: string; id: string }) {
  return (
    <div className="flex h-full w-full flex-col justify-between bg-[#09090b] p-20 text-white">
      <span className="text-3xl font-medium text-emerald-400">Acme</span>
      <div className="flex flex-col">
        <span className="text-2xl text-zinc-400">Product {id}</span>
        <span className="mt-3 text-8xl font-bold tracking-tight">{name}</span>
      </div>
    </div>
  );
}

export default async function ProductOpenGraphImage({ params }: PageProps) {
  const product = await getProduct(params.id);
  return <ProductCard id={params.id} name={product.name} />;
}

For /products/42, Farm calls the component with params.id === "42", serves the generated PNG at /products/42/opengraph-image, and emits og:image, og:image:width, og:image:height, and og:image:alt tags. A nested page such as /products/42/reviews inherits this image until a nearer route segment defines its own. The component and its data-loading code remain on the server.

className supports the image renderer's Tailwind utility set, including arbitrary values. Inline style can be used with it and wins when both set the same property. This is not a browser screenshot: image JSX supports flex layout, typography, borders, gradients, absolute positioning, and embedded images, but not CSS Grid, animations, media queries, pseudo-elements, hooks, or stateful and class components. The application stylesheet and custom Tailwind plugins are not executed. Use an absolute URL for an <img> source.

Farm uses 1200 by 630 when size is omitted and includes Geist as the default font. Export fonts to embed a custom brand font:

const brandFont = fetch("https://cdn.example.com/fonts/Brand-Bold.ttf").then((response) =>
  response.arrayBuffer(),
);

export const fonts = [
  { name: "Brand", data: brandFont, weight: 700 as const, style: "normal" as const },
];

Satori-compatible TTF, OTF, and WOFF files are supported; WOFF2 is not. Set fontFamily: "Brand" on the element that uses the font.

Generated images revalidate on every request by default. Export revalidate = 300 to let a CDN cache the route for five minutes, or revalidate = false only when the output is permanently immutable. Farm emits an ETag, supports conditional requests and HEAD, and performs JSX-to-image rendering internally.

For advanced renderers, the default export may still return a Response, string, or bytes. To preserve the earlier React-to-SVG behavior, export contentType = "image/svg+xml" and return an SVG React element. A returned Response keeps its own status, headers, and body.

Keep only one implementation for each image kind in a segment. For example, defining both opengraph-image.png and opengraph-image.tsx produces a build error. For broad social-platform compatibility, use 1200 by 630; generated JSX routes emit PNG automatically.

Image responses in API routes

When an image needs its own URL, such as a share card per record or a badge another site embeds, return ImageResponse from an API route. @farm.js/core/og exports the same @vercel/og renderer that generated metadata images use. Install it once for both with pnpm add @vercel/og.

src/app/api/og/route.tsx
import { ImageResponse } from "@farm.js/core/og";

export async function GET(request: Request) {
  const title = new URL(request.url).searchParams.get("title") ?? "Acme";

  return new ImageResponse(
    <div
      style={{
        display: "flex",
        width: "100%",
        height: "100%",
        alignItems: "center",
        justifyContent: "center",
        background: "#09090b",
        color: "white",
        fontSize: 72,
      }}
    >
      {title}
    </div>,
    { width: 1200, height: 630 },
  );
}

ImageResponse is a standard Response with a PNG body. Its options take width, height, fonts, emoji, and debug alongside the usual status and headers. The element goes to the renderer as written, so style it with style or the tw prop; the className conversion described above applies only to metadata image files. Like generated metadata images, it takes React JSX.

Outside development the response defaults to cache-control: public, immutable, no-transform, max-age=31536000. Pass a lowercase cache-control header when the image can change, for example headers: { "cache-control": "public, max-age=300" }.

@farm.js/core/og is server-only. Importing it from a client component fails the build, and the development request, with an error that names the importing module. Importing it from server code without @vercel/og installed fails the same way and names the install command. On Node presets, including node-server and Vercel Functions, Farm copies @vercel/og and its wasm and font files into the server output whenever the server bundle imports it. Edge and Cloudflare Worker presets are not supported yet.

Other meta tags

Use other for a <meta name> tag that has no dedicated metadata field. Each key renders one tag, and an array value renders one tag per item:

import type { Metadata } from "@farm.js/core";

export const metadata: Metadata = {
  other: {
    "apple-itunes-app": "app-id=123456789",
    "format-detection": "telephone=no",
  },
};

Names and values are escaped. other merges one level deep, so a page can replace a key its layout set and keep the rest. Client navigation removes the previous page's other tags before adding the next page's.

File Route States

Use loading.* and error.* next to a file route to define route-local loading and error states. Use .tsx/.jsx with React, Preact, or Solid, .vue with Vue, and .svelte with Svelte. Farm picks the nearest matching boundary, so src/app/dashboard/error.tsx handles /dashboard and nested dashboard pages unless a deeper segment defines its own boundary.

src/app/
  dashboard/
    page.tsx
    loading.tsx
    error.tsx
src/app/dashboard/loading.tsx
import type { LoadingProps } from "@farm.js/core";

export default function DashboardLoading(props: LoadingProps) {
  return <p>Loading {props.path}</p>;
}

loading.tsx is used as the Suspense fallback when the page or nested content suspends while rendering.

src/app/dashboard/error.tsx
"use client";

import type { ErrorProps } from "@farm.js/core";

export default function DashboardError({ error, reset }: ErrorProps) {
  const message = error instanceof Error ? error.message : "Something went wrong";

  return (
    <section>
      <h2>Could not load dashboard</h2>
      <p>{message}</p>
      <button onClick={reset}>Try again</button>
    </section>
  );
}

error.tsx receives error, reset, params, path, search, searchParams, middleware data, and plugin context. The closest route error boundary handles normal render/data failures. Redirects and notFound() still escape to Farm's redirect and not-found handling.

redirect() and notFound()

Import redirect(), permanentRedirect(), and notFound() from @farm.js/core or @farm.js/core/navigation. They work in pages, layouts, server components, route data hooks, and middleware, and a page that hydrates can import them like any other module.

import { notFound, type PageProps } from "@farm.js/core";

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const product = await getProduct(params.id);
  if (!product) notFound();
  return <Product product={product} />;
}

While the response has not started, Farm answers with the real status: 307 or 308 with a Location header, or 404 with the app's configured not-found page. That includes a synchronous page under a loading.tsx boundary, and an async page in production, which Farm awaits before rendering.

When the call happens after a loading boundary already streamed the 200 shell (a component behind <Suspense> that finishes later, or an async page in development), the status can no longer change. Like Next.js, Farm finishes the document so it recovers in the browser: an http(s) redirect replaces the location, and notFound() swaps the page for the app's not-found page, marks the document noindex, and skips hydrating the page that threw. The layouts are already on screen, so the not-found page renders without them; if it needs context a layout provides, Farm shows its built-in not-found page instead.

With experimental RSC enabled, failures before the HTML shell is sent render the nearest error.tsx through RSC and SSR with status 500 and Cache-Control: private, no-store. RSC loading and error boundaries follow the selected page's file ancestors, including route groups and catch-all folders. For example, /users/[id]/error.tsx does not handle a sibling /users/new/page.tsx. Failures before page selection, such as middleware errors, use only the root error boundary when one exists. This fallback uses a standalone document shell, outside the failed page/layout tree, so a broken layout cannot prevent the error UI from rendering. Client boundaries receive a client-owned reset() that reloads the current URL. Production responses show a generic error message; the original exception stays in server logs. Development responses can show the error message.

If no boundary exists, or the boundary itself fails during SSR, Farm returns a generic non-cacheable 500 response. Failures after streaming starts cannot change the already-sent status code and remain subject to React's streaming recovery behavior.

Place not-found.* at the app root to customize unmatched URLs. When the component lives elsewhere, set its project-relative path explicitly; Farm uses the same file in development and production and fails startup or build when the path is missing or incompatible with the selected renderer:

farm.config.ts
export default defineConfig({
  notFound: {
    component: "./src/ui/not-found.tsx",
  },
});

Catch-all routes

Catch-all routes are useful for docs, CMS content, and nested marketing pages where the page is resolved from content instead of a fixed file for every URL.

src/app/docs/[...slug]/page.tsx
import type { PageProps } from "@farm.js/core";

export default function DocsPage({ params }: PageProps<"/docs/[...slug]">) {
  const slug = params.slug.split("/");
  return <main>Docs path: {slug.join(" / ")}</main>;
}

Route groups

Use route groups to organize files without adding URL segments. They are useful when an app has multiple shells.

Route groups
src/app/
  (marketing)/
    page.tsx
    pricing/
      page.tsx
  (app)/
    dashboard/
      page.tsx

The group names are organizational. The URLs are still /, /pricing, and /dashboard.

Pending Navigation UI

Use useNavigation() for global route-transition UI: top progress bars, disabled navigation buttons, optimistic shells, or app-wide busy indicators. It tracks SPA navigations started by Link and navigateTo.

"use client";

import { useNavigation } from "@farm.js/core/client";

export function TopProgress() {
  const navigation = useNavigation();

  return <div data-pending={navigation.pending} aria-hidden={!navigation.pending} />;
}

navigation.state is "loading" while route data is being fetched and returns to "idle" after the route is committed. navigation.to includes the target pathname, search, hash, and full href. Use route loading.tsx for segment-level Suspense fallbacks, and useNavigation() when the surrounding app shell should react to a navigation.

View Transitions

Pass viewTransition to Link or navigateTo when a navigation should use the browser View Transitions API. Farm starts the transition after route data is ready and falls back to normal SPA navigation when the browser does not support it.

import { Link, navigateTo } from "@farm.js/core/client";

export function GalleryLink() {
  return (
    <Link href="/gallery" viewTransition>
      Gallery
    </Link>
  );
}

await navigateTo("/gallery", { viewTransition: true });

Use this for image galleries, dashboards that keep the same app shell, settings panes, and modal-to-page flows. Keep important loading states in loading.tsx or useNavigation(); view transitions are visual polish, not a loading boundary.

  1. Add or rename route files.
  2. Keep farm dev running; Farm regenerates route and API types when route files change.
  3. Use Link for internal navigation and plain anchors for external URLs.
  4. Keep dynamic route values encoded in the href string, such as /blog/${slug}.

Run farm generate when you want to refresh generated types outside the dev server, such as in CI or after a large file move.