Farm.js

Content

@farm.js/content turns local Markdown, MDX, JSON, and YAML files into typed server data. Farm discovers the files, validates them while configuring the application, watches them in development, and bundles the validated result into production output.

Collections are ordinary plugin options in farm.config.ts.

Install

pnpm add @farm.js/content zod

Zod is optional. The plugin accepts Standard Schema validators, including Valibot and ArkType, or a small custom validator with parse or parseAsync.

Define a collection

farm.config.ts
import { collection, content, files } from "@farm.js/content";
import { defineConfig } from "@farm.js/core";
import { z } from "zod";

const post = z.object({
  title: z.string().min(1),
  description: z.string().min(1),
  publishedAt: z.coerce.date(),
  tags: z.array(z.string()).default([]),
  draft: z.boolean().default(false),
});

export default defineConfig({
  plugins: [
    content({
      collections: {
        posts: collection({
          source: files("content/posts/**/*.{md,mdx}"),
          schema: post,
          transform: ({ data, words }) => ({
            ...data,
            readingMinutes: Math.max(1, Math.ceil(words / 220)),
          }),
        }),
      },
    }),
  ],
});

Adding content() enables the plugin. Remove it from plugins to disable content processing. There is no separate enabled flag.

Content source files must resolve inside the app root, including when a glob's directory contains a symlink. Links to files elsewhere in the app keep their logical entry IDs and are watched for changes; links that resolve outside the app are rejected before the file is read.

The schema validates frontmatter for Markdown and MDX. For JSON and YAML, it validates the complete file value. transform runs after validation and can add or replace fields. Its output becomes the inferred type returned to the application.

Markdown and MDX frontmatter is data only: use YAML (the default, or ---yaml / ---yml) or JSON (---json). Executable formats such as ---js and ---javascript are rejected before parsing or schema validation, including empty frontmatter blocks. Put computed metadata in transform instead.

Asset declarations are separate from schema. A schema string remains a string; Farm never changes its output into image metadata behind the validator's back.

Write content

content/posts/hello-farm.md
---
title: Hello, Farm
description: A first typed post.
publishedAt: 2026-09-09
tags:
  - Farm.js
---

The Markdown body remains available separately from validated frontmatter.

Manage local assets

Use assets: true when a collection only needs relative assets from Markdown or MDX body syntax:

collection({
  source: files("content/posts/**/*.{md,mdx}"),
  schema: z.object({ title: z.string() }),
  assets: true,
});

URL-encode filename characters such as #, ?, and %: [Guide](./guide%23v1.pdf) refers to guide#v1.pdf. Farm gives these assets a safe generated filename before passing them through Vite's normal asset pipeline. Their original metadata names, URL query strings, fragments, and development reload behavior are preserved. The generated copies live under .farm/content/assets and are recreated when the app's generated .farm directory is cleared.

Farm recognizes inline and reference-style Markdown images and file links:

![Architecture](./architecture.png)

[Download the guide](./guide.pdf)

![Release diagram][release-diagram]

[release-diagram]: ./release-diagram.svg

Reference labels follow Markdown's case-insensitive matching. If a label is defined more than once, the first definition wins; later duplicates are left unchanged and do not add asset imports or missing-file errors.

Relative image destinations are checked as real images with intrinsic dimensions. Relative file links are managed when their destination has a file extension; extensionless links and links to .md or .mdx remain content navigation. Remote URLs, root-relative URLs, fragments, data URLs, and dynamic MDX expressions are left unchanged. Static src or href attributes inside JSX and HTML are not interpreted in this first API; use Markdown syntax or a normal module import there.

For .mdx files, JavaScript strings, comments, imports/exports, and JSX attributes are not scanned for Markdown assets. For example, {"![Example](./missing.png)"} stays literal code and does not require that image to exist. Actual Markdown images and links nested inside JSX are still managed. Farm parses MDX syntax without executing expressions or importing modules; invalid syntax reports the content filename. Ordinary .md files keep Markdown parsing rules.

The body keeps its original Markdown except for managed destinations, which become content-hashed public URLs. entry.bodyAssets contains the corresponding image or file metadata for a custom Markdown component mapping. Missing assets report the content file plus the Markdown line and column. Asset changes participate in development reloads.

Type frontmatter assets

Declare frontmatter assets by the field names used by the application. They do not belong in the ordinary schema:

import { asset, collection, content, files } from "@farm.js/content";
import { defineConfig } from "@farm.js/core";
import { z } from "zod";

export default defineConfig({
  plugins: [
    content({
      collections: {
        posts: collection({
          source: files("content/posts/**/*.{md,mdx}"),
          schema: z.object({
            title: z.string(),
            description: z.string(),
          }),
          assets: {
            image: asset.image().optional(),
            downloads: asset.files().default([]),
          },
        }),
      },
    }),
  ],
});
---
title: Introducing Farm Content
description: A typed local publishing workflow.
image: ./preview.png
downloads:
  - ./guide.pdf
  - ./example.zip
---

The frontmatter remains concise while its resolved values are fully typed.

The asset fields are removed before the Standard Schema validator runs, resolved relative to the content file, then merged back into entry.data:

post.data.title; // string
post.data.image; // ContentImageAsset | undefined
post.data.downloads; // readonly ContentFileAsset[]

asset.image() validates one required image. asset.images() validates an array of images; asset.file() and asset.files() provide the equivalent generic-file forms. Add .optional() or .default(...) when the frontmatter value may be omitted. Declarations can be nested to match a nested frontmatter object.

Images expose src, width, height, type, bytes, name, and the original source. Their shape can be passed directly to Farm's Image component. Files expose the same fields except for image dimensions. All managed paths must be relative, resolve to ordinary files inside the Farm project, and use forward slashes. Keep remote or public-root URLs in the ordinary schema when the application intentionally owns those strings.

Entry IDs

Entry IDs come from the path below the common static directory of the source globs. The extension and a trailing index are removed:

Source fileEntry ID
content/posts/hello-farm.mdhello-farm
content/posts/guides/getting-started.mdxguides/getting-started
content/posts/guides/index.mdguides

Pass files(pattern, { base: "content" }) when the ID should use an explicit base. The ignore option accepts one glob or an array of globs.

Read typed entries

Use the server entry from a Server Component, static path function, server query, server function, or API route.

import { getCollection } from "@farm.js/content/server";

export default async function BlogPage() {
  const posts = (await getCollection("posts"))
    .filter((entry) => !entry.data.draft)
    .sort((left, right) => right.data.publishedAt.getTime() - left.data.publishedAt.getTime());

  return (
    <ul>
      {posts.map((entry) => (
        <li key={entry.id}>
          {entry.data.title} · {entry.data.readingMinutes} min
        </li>
      ))}
    </ul>
  );
}

Each entry contains:

FieldMeaning
idStable path-derived identifier used with getEntry.
dataValidated schema output, followed by the optional transform.
bodyMarkdown or MDX body. Managed asset destinations contain emitted URLs.
bodyAssetsImage and file metadata discovered from managed Markdown body syntax.
filePathProject-relative source path, useful for diagnostics and editorial tools.

Read one entry when the route already has its ID:

import { getEntry, getEntryOrThrow } from "@farm.js/content/server";

const optionalPost = await getEntry("posts", "guides/getting-started");
const post = await getEntryOrThrow("posts", "guides/getting-started");

getEntry returns undefined for a miss. getEntryOrThrow includes the collection and ID in its error, which is usually better for a path Farm already generated from the same collection.

Generate static pages

Use a catch-all route when IDs can contain /.

src/app/posts/[...slug]/page.tsx
import { getCollection, getEntryOrThrow } from "@farm.js/content/server";
import type { PageProps } from "@farm.js/core";

export const dynamic = "force-static";

export async function getStaticPaths() {
  return (await getCollection("posts")).map((entry) => ({
    slug: entry.id.split("/"),
  }));
}

export default async function PostPage({ params }: PageProps<"/posts/[...slug]">) {
  const post = await getEntryOrThrow("posts", params.slug);
  return <article>{post.body}</article>;
}

Use a hosted CMS

A collection's source does not have to be files. remote() is the one interface every CMS, API, or database plugs into - implement it once and the rest of this page applies unchanged: the same schema validation, transforms, generated types, and server reads and writes.

The source interface

import { collection, content, remote } from "@farm.js/content";

source: remote({
  // names the source in error messages, e.g. "mycms:posts"
  name: "mycms:posts",

  // REQUIRED - resolve to an array of documents:
  //   id:   stable, route-friendly, unique in the collection (a slug)
  //   data: goes through the collection schema, so YOUR schema is the boundary
  //   body: optional Markdown, for entry.body and word counts
  fetch: async () => [{ id, data, body }],

  // OPTIONAL - dev only: re-fetch this often and reload when documents changed
  refreshInterval: 30_000,

  // OPTIONAL - the write surface, one callback per verb, same shape as fetch.
  // Each returns the provider's CONFIRMED document; omit any verb and
  // collections.<name>.<verb>() says exactly what is missing.
  create: async ({ data, body }) => confirmedDocument,
  update: async (id, patch) => confirmedDocument,
  delete: async (id) => {},
}),

That is the entire integration surface. Everything below holds for every provider:

  • Snapshot semantics. Documents are fetched while the configuration loads, validated, and bundled into production output. Publishing means rebuilding: point the provider's webhook at a deploy hook. Entries sort by ID so fetch order never churns the generated module.
  • Development. refreshInterval polls and reloads only when documents actually changed. A successful write rebuilds the snapshot immediately.
  • Writes. collections.<name>.create/update/delete route through the callbacks, and the confirmed document is re-validated against the collection schema before anyone trusts it. In production the write lands in the CMS while the running deployment serves the bundled snapshot until the next build. Gate every write behind your own server function: Farm never exposes content writes to the browser.
  • Limits. Remote collections cannot declare typed asset fields, which resolve files on disk.

Reading and writing entries

import { collections } from "@farm.js/content/server";

const posts = await collections.posts.all(); // the validated snapshot
await collections.posts.update("hello-world", { data: { title: "New title" } });
await collections.posts.create({ data: { title: "Drafted from the app" } });
await collections.posts.delete("old-post");

Official sources

Provider packages implement the interface for you - each is one import plus its credentials, and everything above applies as written:

ProviderSourceSetup
SanitysanitySource from @farm.js/sanitySanity integration → Content collections
ContentfulcontentfulSource from @farm.js/contentfulbelow

Contentful setup

import { contentfulSource } from "@farm.js/contentful";

posts: collection({
  source: contentfulSource({ contentType: "post", query: { order: ["-sys.createdAt"] } }),
  schema: post,
});
  • credentials resolve from CONTENTFUL_SPACE_ID and CONTENTFUL_ACCESS_TOKEN, or pass an existing client.
  • entry IDs default to a string fields.slug, falling back to sys.id.
  • pagination past Contentful's 1000-entry page cap is handled internally, bounded by the reported total; limit caps the total entries loaded. content_type and skip are owned by the source, everything else in query passes through.
  • set host: "preview.contentful.com" with CONTENTFUL_PREVIEW_TOKEN to load drafts.
  • the source is read-only for now: Contentful writes go through its separate management SDK in a server function.

Bring your own CMS

Any system that can answer "give me the documents" plugs in the same way - here is the whole direction, using WordPress's REST API as a stand-in for whatever you run. No Farm package, no SDK, just the interface:

import { collection, content, remote } from "@farm.js/content";

const wordpress = remote({
  name: "wp:posts",
  fetch: async () => {
    const posts = await fetch("https://blog.example.com/wp-json/wp/v2/posts?per_page=100").then(
      (response) => response.json(),
    );
    return posts.map((post) => ({
      id: post.slug, // 1. pick a stable, route-friendly id
      data: { title: post.title.rendered, publishedAt: post.date }, // 2. shape data for YOUR schema
      body: post.content.rendered, // 3. optional body
    }));
  },
  refreshInterval: 30_000, // 4. dev reloads when documents change

  // 5. optional: implement writes and collections.<name>.update() lights up
  update: async (id, patch) => {
    const updated = await wpApi(`posts?slug=${id}`, { method: "POST", body: patch.data });
    return { id, data: updated };
  },
});

The direction, in order:

  1. Map documents to { id, data, body? } in fetch - the provider's SDK, a REST call, a database query, anything.
  2. Pick stable IDs - a slug beats an internal database id, because entry IDs become routes.
  3. Let your schema be the boundary - shape data for the collection schema and consumers never learn which CMS is behind it. Swapping providers later changes only the source: line.
  4. Wire updates - the provider's publish webhook points at your deploy hook, and refreshInterval covers development.
  5. Add writes when the app needs them - one callback per verb, each returning the provider's confirmed document, gated behind your own server functions.

Even the official providers work this way

The packages under @farm.js/* are the same interface published with conventions attached - nothing more. The callbacks are ordinary functions, so the natural way to bring your own Sanity is your own @sanity/client instance: GROQ for reads (mutations are not GROQ - Sanity's mutation API is create/patch/delete, in the SDK and over HTTP alike), and the client's mutation methods for writes:

import { createClient } from "@sanity/client";

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: "2026-03-01",
  token: process.env.SANITY_API_WRITE_TOKEN, // server-only; enables the write callbacks
  useCdn: false, // builds want the freshest documents
});

// The collection's read set and its id mapping, shared with the write
// callbacks below.
const postsQuery = `*[_type == "post"]{ _id, slug, title, publishedAt }`;
const entryId = (doc) => doc.slug?.current ?? doc._id;

// Mutations address `_id`, entry ids are slugs: resolve the bridge against the
// same query the collection reads, so a write can only ever land on a document
// this collection owns. A bare `*[slug.current == $id][0]._id` would happily
// resolve an unrelated `_type` that shares the slug - and then `delete` it.
const resolveDocumentId = async (id) => {
  const docs = await sanity.fetch(postsQuery);
  const match = docs.find((doc) => entryId(doc) === id);
  if (!match) throw new Error(`sanity:posts has no document for ${id}`);
  return match._id;
};

const sanityPosts = remote({
  name: "sanity:posts",

  fetch: async () => {
    const docs = await sanity.fetch(postsQuery);
    return docs.map((doc) => ({ id: entryId(doc), data: doc }));
  },

  update: async (id, patch) => {
    const updated = await sanity
      .patch(await resolveDocumentId(id))
      .set(patch.data)
      .commit();
    return { id, data: updated };
  },

  create: async ({ data }) => {
    const doc = await sanity.create({ _type: "post", ...data });
    return { id: entryId(doc), data: doc };
  },

  delete: async (id) => {
    await sanity.delete(await resolveDocumentId(id));
  },
});

This is, almost line for line, what sanitySource does for you - it only adds the env conventions, this write-target resolution, and the error naming. The same also works with no SDK at all, over Sanity's plain HTTP API:

const sanityApi = `https://${process.env.SANITY_PROJECT_ID}.api.sanity.io/v2026-03-01`;
const dataset = process.env.SANITY_DATASET;

const sanityPosts = remote({
  name: "sanity:posts",

  fetch: async () => {
    const query = encodeURIComponent(`*[_type == "post"]{ _id, slug, title, publishedAt }`);
    const { result } = await fetch(`${sanityApi}/data/query/${dataset}?query=${query}`).then(
      (response) => response.json(),
    );
    return result.map((doc) => ({ id: doc.slug?.current ?? doc._id, data: doc }));
  },

  // Mutations are one endpoint away; the write surface, schema re-validation,
  // and dev rebuild behave identically to the SDK version above.
  update: async (id, patch) => {
    const response = await fetch(`${sanityApi}/data/mutate/${dataset}?returnDocuments=true`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${process.env.SANITY_API_WRITE_TOKEN}`,
      },
      body: JSON.stringify({
        mutations: [
          // Constrained to the collection's own `_type`, for the same reason
          // the SDK version resolves against its read query.
          {
            patch: {
              query: `*[_type == "post" && slug.current == $id][0]`,
              params: { id },
              set: patch.data,
            },
          },
        ],
      }),
    });
    const { results } = await response.json();
    return { id, data: results[0].document };
  },
});

Contentful reads inline the same way:

fetch: async () => {
  const url =
    `https://cdn.contentful.com/spaces/${process.env.CONTENTFUL_SPACE_ID}` +
    `/environments/master/entries?access_token=${process.env.CONTENTFUL_ACCESS_TOKEN}&content_type=post`;
  const { items } = await fetch(url).then((response) => response.json());
  return items.map((item) => ({ id: item.fields.slug ?? item.sys.id, data: item.fields }));
},

What the inline versions leave to you is exactly what the packages add: pagination past provider page caps, linked-entry resolution, slug-to-id mapping for writes, lazy credential errors, and preview switches. Use whichever tier fits - they are interchangeable, and this path is covered by the plugin's own test suite, so it stays working.

A dedicated @farm.js/<provider> package is only ever ergonomics on top of this: conventional env vars, a default id, provider-aware errors. Start with remote(); extract the package when the conventions settle.

Publishing a source package

Anyone can ship an adapter - a CMS vendor, a community author. Farm's own sanitySource and contentfulSource use nothing a third party cannot: they import exactly two type-only names from @farm.js/content and return the plain remote() shape. The conventions that make an adapter feel first-party:

import type { ContentRemoteDocument, ContentRemoteSource } from "@farm.js/content";

export function myCmsSource(options: MyCmsSourceOptions): ContentRemoteSource {
  // ...
}
  1. Type-only dependency. Import only types from @farm.js/content; declare it as an optional peer. Your package then has zero runtime coupling to Farm and works with every version that keeps the shape.
  2. The provider SDK is a peer dependency with literal imports, so bundlers can trace it and apps control the version.
  3. Resolve credentials lazily, from conventional env vars (MYCMS_TOKEN) with explicit options and an existing-client escape hatch. Importing farm.config.ts without the env set must not throw; the first fetch() should, with a message naming the env vars.
  4. Pick route-friendly default IDs (a slug over an internal id) and document the fallback. If writes address documents differently than reads identify them, resolve the mapping inside the source, the way sanitySource maps slugs back to _id.
  5. Name errors after yourself. Every thrown message should carry the source name, so a failing build says which provider and which document.
  6. Implement writes only when the provider can confirm them. Each verb returns the stored document, which Farm re-validates against the collection schema. Omit verbs you cannot honor - the collection handle explains what is missing better than a broken write.
  7. Test against the real SDK shape - the reference suites in packages/farm-sanity and packages/farm-contentful cover fetching, id derivation, pagination, credential failures, and write flows, and are the template worth copying.

An adapter that follows these is indistinguishable from an official one. Open a pull request to add it to the table above.

For live content that must update without a rebuild, keep the provider SDK in a server-only module and fetch through a Server Query instead. Farm can validate the response, cache published content, and share the typed result with server or client consumers.

For example, a Sanity-backed query can stay small:

src/lib/cms.ts
import { createServerQuery } from "@farm.js/core/server-query";
import { createClient } from "@sanity/client";
import { z } from "zod";

const Post = z.object({
  id: z.string(),
  title: z.string(),
  slug: z.string(),
});

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  apiVersion: "2026-09-09",
  useCdn: true,
});

export const cmsPosts = createServerQuery({
  output: z.array(Post),
  key: () => ["cms", "posts"],
  staleTime: "5m",
  handler: () =>
    sanity.fetch(`*[_type == "post"]{
      "id": _id,
      title,
      "slug": slug.current
    }`),
});

A Server Component can call await cmsPosts(). Point a verified CMS webhook at a Farm API route and call await invalidate(["cms", "posts"]) when published content changes; see Cache and PPR. For draft previews, use a server-only read token, bypass the provider CDN, authorize the request, and avoid a shared persistent cache. Never expose a CMS token to a Client Component.

The same shape works with another provider: replace the client and query language, while keeping the server-only boundary, output schema, cache key, and invalidation path. See the Sanity JavaScript client guide for provider-specific setup.

Type generation

Farm adds content inference to the same src/farm.d.ts used for routes and environment values. It reads the plugin type from farm.config.ts, so collection keys, Zod output, coerced Date values, and transform results flow into getCollection and getEntry without a handwritten registry.

Development and production builds refresh the file automatically. Run this after changing config if an editor is open before the development server:

farm generate

Commit src/farm.d.ts when the application already commits Farm's generated declarations.

Split a large setup

An inline config is the shortest starting point. If collections grow, extract ordinary values to a server-owned application module rather than introducing another framework config:

src/content.ts
import { collection, files } from "@farm.js/content";
import { z } from "zod";

export const collections = {
  posts: collection({
    source: files("content/posts/**/*.md"),
    schema: z.object({ title: z.string() }),
  }),
};
farm.config.ts
import { content } from "@farm.js/content";
import { defineConfig } from "@farm.js/core";
import { collections } from "./src/content";

export default defineConfig({ plugins: [content({ collections })] });

The plugin still receives the collection through its options. The extra file is only organization chosen by the application.

Rendering and security

The plugin returns body text and does not execute MDX or emit HTML automatically. Without the assets option, the body is unchanged. With asset handling enabled, only recognized Markdown destinations are rewritten. This keeps the collection renderer-neutral and lets the application choose its component mapping, Markdown renderer, and sanitization policy. Treat author-provided HTML as untrusted unless the source is controlled and the chosen renderer sanitizes it.

@farm.js/content/server fails the client build when imported into a browser environment. Use a Server Query or API route when a Client Component needs content data.

Development and production

  • Development regenerates content after supported source files are added, changed, or removed, then reloads the current page. Referenced asset changes do the same. Validation failures appear in Vite's error overlay with the collection, source file, field path or Markdown position, and validation message.
  • Production serializes only validated values into a private .farm/content/server.mjs build input. Nitro bundles that module, so the deployed server does not read project source files per request. Generated output must stay inside the app root. Outside or dangling output symlinks fail before Farm creates directories or writes files; output-directory links within the app remain supported.
  • Dates and bigints keep their runtime types. Plain objects, arrays, undefined, and primitives are preserved. Functions, symbols, class instances, circular structures, invalid dates, and non-finite numbers fail the build instead of being silently changed.

The runnable examples/content-demo shows inline configuration, Zod coercion, computed reading time, nested IDs, static generation, and the finished editorial UI.