API Routes
Expose HTTP handlers from src/app/api and validate input with schemas before handler code runs.
Route handlers
API route modules export HTTP methods. Farm discovers them, runs the route pipeline, and can generate typed client callers from the route shape.
Routes in one app source must have distinct URL shapes. For example, /api/users/[id] and
/api/users/[slug] conflict even though their parameter names differ. Parameters must have unique,
non-reserved names, and catch-alls must be the final segment. These checks also run in RSC builds.
If a project overrides a layer's equivalent dynamic shape, the project route replaces it completely,
so handlers do not receive parameters named by the lower-priority route.
import { createEndpoint } from "@farm.js/core/api";
import { z } from "zod";
export const POST = createEndpoint(
{
method: "POST",
body: z.object({
name: z.string().min(1),
}),
},
async ({ body }) => {
return Response.json({ message: "Hello " + body.name });
},
);Use createApiClients() in one shared src/lib/api.ts to return { api, apiClient }
from the generated router. Both callers reuse this route definition: api dispatches locally on
the server and apiClient sends HTTP requests. Endpoint middleware runs for both; outer HTTP
middleware is not replayed by direct calls. Never import endpoint modules into the shared caller file.
Next-style exports
You can also manually export GET, POST, PATCH, and other handlers from the route file. Farm keeps this familiar while layering typed helpers around it.
Generated API client types follow runtime value exports. Comments, string examples, and type-only exports named after HTTP methods do not create callable endpoints.
export async function GET() {
return Response.json({ ok: true });
}Validation
Zod and standard schema
Endpoint and integration route inputs can use Zod or compatible standard-schema validators so the handler sees parsed input instead of raw unknown data.
Route file shape
Farm follows the familiar route-file convention: each route lives in src/app/api/**/route.ts and exports one or more HTTP methods.
The equivalent route.tsx, route.js, and route.jsx filenames use the same discovery and generated-type behavior.
src/app/api/hello/route.ts -> /api/hello
src/app/api/users/[id]/route.ts -> /api/users/:id
src/app/api/files/[...path]/route.ts -> /api/files/*Static route directory names may contain Unicode. Browsers percent-encode those path segments; Farm decodes both the discovered route and request segment before matching them.
Defining the same method and path twice within one app source is an error, including in production RSC builds. The error names both files. Project routes may still override matching methods from a layer; other methods on the layer route remain available.
Use createEndpoint when you want input validation and typed client generation. Use plain GET, POST, PATCH, and friends when you want to handle the raw Request.
Plain handlers always receive that Request; parameter names such as request, context, or ctx
do not change the calling convention. Use createEndpoint for the parsed { body, query, headers }
context.
HEAD follows normal HTTP semantics. A route can export a dedicated HEAD handler, otherwise Farm
uses its GET handler and returns the same status and headers without a response body.
This also applies to production RSC builds. They support QUERY exports, prefer an explicit
HEAD handler, and return 405 with an Allow header for unsupported methods.
Body and query input
export const GET = createEndpoint(
{
method: "GET",
query: z.object({
q: z.string().optional(),
page: z.coerce.number().int().positive().default(1),
}),
},
async ({ query }) => {
return Response.json({
q: query.q ?? "",
page: query.page,
});
},
);Farm parses and validates body, query, and headers before middleware or handler code runs. Header schema keys use the lower-case names exposed by the Fetch Headers API. Invalid input returns a 400 response with structured validation issues.
Generated callers use the schema's input type for body and query values; middleware and
handlers receive its parsed output type. For example, a field declared as
z.string().transform(Number) is sent as a string and received by the handler as a number.
Defaulted fields may be omitted by the caller. The same distinction applies to multipart
toFormData(...) inputs, Standard Schema input/output types, and both api and apiClient
from createApiClients(). Validation still runs on the server; this does not send schemas or
transforms to the browser. Existing endpoints without transforms retain their input types.
Malformed application/json and application/*+json bodies also return 400 before endpoint
middleware or handler code executes, including when the endpoint does not declare a body schema.
If an upload is aborted while Farm is buffering its body, Farm rejects it before invoking the
endpoint. This does not roll back work in a handler that has already started.
On Node, finishing an upload is not an abort: only an interrupted upload or an early response
disconnect cancels the request signal.
HTTP QUERY
Use the standardized QUERY HTTP method when a
read operation needs structured request content that is too large or sensitive for a URL. Like
GET, QUERY is safe and idempotent; unlike GET, its request body has defined semantics.
import { QUERY as createQueryEndpoint } from "@farm.js/core/api";
import { z } from "zod";
export const QUERY = createQueryEndpoint(
{
body: z.object({
filters: z.array(z.object({ field: z.string(), value: z.string() })),
limit: z.number().int().min(1).max(100).default(20),
}),
},
async ({ body }) => {
const products = await searchProducts(body.filters, body.limit);
return { products, total: products.length };
},
);The helper infers the validated body inside the handler and exposes the same input and response
types to the generated client. QUERY requests must include a Content-Type header; Farm's generated
client sets application/json automatically. A raw handler is also valid:
export async function QUERY(request: Request) {
const search = await request.json();
return Response.json(await searchProducts(search));
}Keep QUERY handlers read-only. Use POST, PATCH, or another unsafe method when the operation
changes server state. A server can advertise accepted query media types with an Accept-Query
response header. Cross-origin browser requests use a CORS preflight, so include QUERY in the
configured cors.methods list when that list is restricted.
Uploads and streaming results
Use multipart() when an endpoint accepts files. Farm parses the request as FormData, preserves
Blob/File values and repeated fields, then runs the same body-schema validation used for JSON:
import { createEndpoint, jsonStream, multipart } from "@farm.js/core/api";
import { z } from "zod";
const importBody = multipart(
z.object({
title: z.string().min(1),
file: z.custom<Blob>((value) => value instanceof Blob),
}),
);
type ImportEvent = { phase: "accepted"; bytes: number } | { phase: "complete"; imported: number };
export const POST = createEndpoint(
{
method: "POST",
body: importBody,
},
async ({ body }) => {
async function* importEvents(): AsyncGenerator<ImportEvent> {
yield { phase: "accepted", bytes: body.file.size };
const imported = await importRows(body.file);
yield { phase: "complete", imported };
}
return jsonStream(importEvents());
},
);jsonStream() uses newline-delimited JSON (application/x-ndjson). It sends each typed event as
soon as the source yields it, respects response backpressure, cancels the source when the reader
disconnects, and defaults to Cache-Control: no-store. Use ordinary Response objects for binary
downloads or protocols that are not JSON event streams.
The client decoder rejects malformed NDJSON with its original SyntaxError and cancels the
source without waiting for cleanup or an unread response clone. Explicit stream cancellation
and early iterator return still await the producer's cleanup.
When Farm bridges a streamed Response to Node, a failed response write also cancels the
upstream body. Put producer cleanup in the stream's cancel() callback. Farm preserves the
original write error and does not wait indefinitely for that cleanup to finish.
Disconnects follow the same rule, including when the connection closes under backpressure:
Farm releases its reader and response listeners without waiting for app-owned cancellation work.
Endpoint middleware
Put plain async functions in middleware. There is no middleware factory and no next() callback. Functions run in declaration order after endpoint input validation.
import { createEndpoint, type EndpointMiddlewareContext } from "@farm.js/core/api";
import { z } from "zod";
type Session = {
user: { id: string; roles: string[] };
};
async function requireAuth({ request }: EndpointMiddlewareContext) {
const session = await getSession(request);
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return { session: session as Session };
}
const requireRole =
(role: string) =>
async ({ context }: EndpointMiddlewareContext<{ session: Session }>) => {
return context.session.user.roles.includes(role);
};
async function loadProject({
body,
context,
params,
}: EndpointMiddlewareContext<{ session: Session }, { name: string }>) {
const project = await db.project.findUniqueOrThrow({
where: {
id: String(params.id),
ownerId: context.session.user.id,
},
});
return { project };
}
export const PATCH = createEndpoint(
{
method: "PATCH",
body: z.object({ name: z.string().min(1) }),
middleware: [requireAuth, requireRole("admin"), loadProject],
},
async ({ body, context }) => {
// context.session and context.project are inferred from middleware returns.
return db.project.update({
where: { id: context.project.id },
data: { name: body.name },
});
},
);Middleware return values have deliberate control-flow meaning:
| Return value | Result |
|---|---|
| Plain object | Shallow-merges its properties into typed context for later middleware and the handler. |
true | Continues without adding context. |
false | Stops and returns Farm's JSON 403 Forbidden response. |
Response | Stops and returns that response unchanged. Use this for custom status, body, or headers. |
Only literal false is the default denial signal. Returning null, undefined, an array, or another value is an error, which catches forgotten returns instead of silently skipping authorization. Context is shallowly frozen, duplicate keys are rejected, and __proto__, constructor, and prototype cannot be provided as context keys.
Endpoint middleware is local to one createEndpoint declaration and can use its validated input. Use src/app/**/middleware.ts for path-level behavior shared by many routes, such as request tracing, common headers, or an early rewrite before endpoint parsing.
Authorization boundary
Derive users, roles, tenants, and rate-limit identities from the trusted
Requestor server state. Middleware context is created only on the server and is never accepted from client input. Keep resource-specific permission checks close to the resource query even when shared authentication runs in middleware.
Typed expected errors
Declare failures that are part of an endpoint's public contract, then return them through the typed
error function:
export const POST = createEndpoint(
{
method: "POST",
body: z.object({
name: z.string().min(1),
}),
errors: {
duplicate: {
status: 409,
message: "A product with this name already exists",
data: z.object({
existingId: z.string(),
}),
},
forbidden: {
status: 403,
data: z.object({
permission: z.string(),
}),
},
},
},
async ({ body, error }) => {
const existing = await findProductByName(body.name);
if (existing) {
return error("duplicate", {
existingId: existing.id,
});
}
return createProduct(body);
},
);Error codes and payloads are checked in the handler and carried into the generated API client:
const result = await api.products.post({
body: { name },
});
if (result.error?.code === "duplicate") {
// existingId is inferred as string.
showExistingProduct(result.error.data.existingId);
}Farm validates the failure payload before returning a JSON error response. Declared messages and
payloads are public, so do not include secrets. Undeclared exceptions remain unexpected server
errors and are not converted into a declared failure. Endpoints without an errors declaration
keep the existing Error client type.
The generated client makes this endpoint RPC-like to call, but the transport remains a regular HTTP
request and JSON error response. Existing endpoint definitions using schema and fail continue to
work as deprecated aliases; use data and error in new code for consistency with server functions.
Client inference
Routes become client namespaces from their path:
await api.hello.post({
body: {
name: "Ada",
},
});
await api.users.get({
query: {
limit: 10,
},
});The exact generated shape comes from route generation. Body and query schemas become typed caller input, and path segments become the nested api.users.get style namespace. During farm dev, Farm regenerates route/API types when page or API route files are added, changed, or removed. Run farm generate when you want the same refresh outside the dev server.
Declare invalidation with the mutation
When every caller of a mutation makes the same data stale, declare that relationship on the endpoint instead of repeating client-side invalidation:
export const PATCH = createEndpoint(
{
method: "PATCH",
body: z.object({
id: z.string(),
name: z.string().min(1),
}),
invalidates: ({ body }) => [
{ key: ["product", body.id] },
{ key: ["products", "list"] },
{ tag: "products" },
{ path: "/products" },
],
},
async ({ body }) => {
return db.product.update({
where: { id: body.id },
data: { name: body.name },
});
},
);Farm applies declared keys, tags, and paths to the server cache after the handler succeeds. Normal
api.products.patch(...) callers also receive the key invalidations through response metadata, so
matching browser queries become stale without repeating an invalidate option. A response with a
status of 400 or higher, or middleware that stops before the handler, does not invalidate.
The resolver receives validated body, query, and headers plus the accumulated typed middleware
context. Invalidation is declarative rather than inferred: database writes do not reliably reveal
every affected query. Existing client-side invalidate options remain supported for
caller-specific cache relationships, and handlers can continue calling invalidate(...) or
revalidatePath(...) directly.
When to use integrations instead
Use API routes for app-owned endpoints. Use integrations when a provider or feature needs a package-like surface: config validation, lifecycle hooks, database schemas, middleware, providers, and typed callers bundled together.