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
| File | URL |
|---|---|
| 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.
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.tsxThe 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:
| Marker | Target 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
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" },
];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.
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.
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).
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>
</>
);
}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.
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.
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.
Navigation blocking
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
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.
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.
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.
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} />);
}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.
src/app/
page.tsx
dashboard/
page.tsx
settings/
page.tsx
blog/
[slug]/
page.tsx
docs/
[...slug]/
page.tsxThis 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.
# 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, and manifest link. 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",
},
};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:
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.
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.
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.
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 three conventions in src/app and route segments:
| File | Public route | Default return type |
|---|---|---|
sitemap.ts | /sitemap.xml | MetadataRoute.Sitemap |
robots.ts | /robots.txt | MetadataRoute.Robots |
manifest.ts | /manifest.webmanifest | MetadataRoute.Manifest |
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.
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.
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",
};
}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" },
],
};
}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.
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.
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.tsxThe 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:
Acme product catalog previewFarm 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. Farm also adds the nearest matching image to the page head when openGraph.images or twitter.images is not already set.
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.
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.tsximport 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.
"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.
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:
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.
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.
src/app/
(marketing)/
page.tsx
pricing/
page.tsx
(app)/
dashboard/
page.tsxThe 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.
Navigation workflow
- Add or rename route files.
- Keep
farm devrunning; Farm regenerates route and API types when route files change. - Use
Linkfor internal navigation and plain anchors for external URLs. - 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.